MCP 클라이언트 구축하기

MCP 클라이언트 구축하기 (Build an MCP client)

모든 MCP 서버와 통합할 수 있는 나만의 클라이언트를 만드는 방법을 배워 보세요. 이 튜토리얼에서는 MCP 서버에 연결하는 LLM 기반 챗봇 클라이언트를 직접 구축해 봅니다.

출처: 문서

본문

이 튜토리얼에서는 MCP 서버에 연결하는 LLM 기반 챗봇 클라이언트를 구축하는 방법을 배워요. 시작하기 전에 MCP 서버 구축 튜토리얼을 먼저 진행해 두면, 클라이언트와 서버가 어떻게 통신하는지 이해하는 데 도움이 됩니다.

Python

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • Mac 또는 Windows 컴퓨터
  • 최신 Python 버전 설치
  • 최신 버전의 uv 설치
  • Python MCP SDK 2.0.0 이상 사용

환경 설정

먼저 uv로 새 Python 프로젝트를 만들게요.

# Create project directory
uv init mcp-client
cd mcp-client

# Create virtual environment
uv venv

# Activate virtual environment
source .venv/bin/activate

# Install required packages
uv add mcp anthropic python-dotenv

# Remove boilerplate files
rm main.py

# Create our main file
touch client.py
# Create project directory
uv init mcp-client
cd mcp-client

# Create virtual environment
uv venv

# Activate virtual environment
.venv\Scripts\activate

# Install required packages
uv add mcp anthropic python-dotenv

# Remove boilerplate files
del main.py

# Create our main file
new-item client.py

API 키 설정

Anthropic Console에서 Anthropic API 키가 필요해요.

API 키를 저장할 .env 파일을 만들게요.

echo "ANTHROPIC_API_KEY=your-api-key-goes-here" > .env

.env를 .gitignore에 추가하세요.

echo ".env" >> .gitignore

ANTHROPIC_API_KEY를 안전하게 보관하세요!

클라이언트 만들기

임포트와 설정

먼저 임포트와 파일 나머지 부분이 공유할 요소를 설정할게요.

import asyncio
import sys

from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp_types import TextContent

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()  # load environment variables from .env

MODEL = "claude-opus-5"
anthropic = Anthropic()

Client는 프로그램이 서버와 통신하는 단일 객체예요. 도구 나열, 하나 호출, 리소스 읽기가 모두 이 객체의 메서드입니다.

서버 연결 관리

다음으로, 주어진 서버 스크립트에 대해 어떤 프로세스를 실행할지 정할게요.

def server_params(server_script_path: str) -> StdioServerParameters:
    """Describe the subprocess that runs an MCP server

    Args:
        server_script_path: Path to the server script (.py or .js)
    """
    if server_script_path.endswith(".py"):
        command = "python"
    elif server_script_path.endswith(".js"):
        command = "node"
    else:
        raise ValueError("Server script must be a .py or .js file")

    return StdioServerParameters(command=command, args=[server_script_path])

StdioServerParameters는 설정이지 연결이 아니에요. stdio_client()가 이를 stdio 전송으로 바꾸고, Client는 async with 블록에 들어갈 때 그 전송을 엽니다. 둘 다 main()에서 수행할게요.

쿼리 처리 로직

이제 쿼리 처리와 도구 호출을 담당하는 핵심 기능을 추가할게요.

async def process_query(client: Client, query: str) -> str:
    """Process a query using Claude and available tools"""
    messages = [
        {
            "role": "user",
            "content": query
        }
    ]

    tool_list = await client.list_tools()
    available_tools = [{
        "name": tool.name,
        "description": tool.description,
        "input_schema": tool.input_schema
    } for tool in tool_list.tools]

    # Initial Claude API call
    response = anthropic.messages.create(
        model=MODEL,
        max_tokens=1000,
        messages=messages,
        tools=available_tools
    )

    # Process response and handle tool calls
    final_text = []
    tool_results = []

    for content in response.content:
        if content.type == 'text':
            final_text.append(content.text)
        elif content.type == 'tool_use':
            tool_name = content.name
            tool_args = content.input

            # Execute tool call
            result = await client.call_tool(tool_name, tool_args)
            final_text.append(f"[Calling tool {tool_name} with args {tool_args}]")

            tool_results.append({
                "type": "tool_result",
                "tool_use_id": content.id,
                "content": "\n".join(
                    block.text
                    for block in result.content
                    if isinstance(block, TextContent)
                ),
                "is_error": result.is_error
            })

    if tool_results:
        messages.append({"role": "assistant", "content": response.content})
        messages.append({"role": "user", "content": tool_results})

        # Get next response from Claude
        response = anthropic.messages.create(
            model=MODEL,
            max_tokens=1000,
            messages=messages,
            tools=available_tools
        )

        for content in response.content:
            if content.type == 'text':
                final_text.append(content.text)

    return "\n".join(final_text)

call_tool은 CallToolResult를 반환해요. 그 content는 블록 목록이라서 .text를 읽기 전에 TextContent로 좁혀야 해요. 예외를 던지는 도구는 여기서 예외를 던지지 않습니다. 대신 is_error가 설정된 채로 응답하고, 그 플래그를 전달하면 Claude가 메시지를 읽고 다른 것을 시도해요.

대화형 채팅 인터페이스

이제 채팅 루프를 추가할게요.

async def chat_loop(client: Client) -> None:
    """Run an interactive chat loop"""
    print("\nMCP Client Started!")
    print("Type your queries or 'quit' to exit.")

    while True:
        try:
            query = (await asyncio.to_thread(input, "\nQuery: ")).strip()
        except EOFError:
            break

        if query.lower() == 'quit':
            break

        try:
            response = await process_query(client, query)
            print("\n" + response)
        except Exception as e:
            print(f"\nError: {e}")

input()은 블로킹이라서 워커 스레드에서 실행돼요. 그렇게 하면 입력하는 동안 이벤트 루프가 연결을 서비스할 수 있습니다.

메인 진입점

마지막으로 메인 실행 로직을 추가할게요.

async def main() -> None:
    if len(sys.argv) < 2:
        print("Usage: python client.py <path_to_server_script>")
        sys.exit(1)

    async with Client(stdio_client(server_params(sys.argv[1]))) as client:
        tool_list = await client.list_tools()
        tool_names = [tool.name for tool in tool_list.tools]
        print("\nConnected to server with tools:", tool_names)

        await chat_loop(client)


if __name__ == "__main__":
    asyncio.run(main())

이 async with가 전체 연결 수명주기예요. 들어갈 때 서버를 실행하고 프로토콜 버전을 합의하며, 나갈 때 연결을 끊고 서브프로세스를 종료합니다. 손으로 닫을 것은 아무것도 없어요.

완전한 client.py 파일은 여기에서 확인할 수 있어요.

핵심 구성 요소 설명

1. 클라이언트 초기화
  • 단일 Client가 연결을 지니고, async with가 그 전체 수명주기예요
  • 호출할 connect/close 쌍도, 나중에 정리할 것도 없어요
  • Claude 상호작용을 위해 Anthropic 클라이언트를 구성해요
2. 서버 연결
  • Python과 Node.js 서버를 모두 지원해요
  • 서버 스크립트 타입을 검증해요
  • 서버를 서브프로세스로 실행하고 stdio로 통신해요
  • 연결이 열리면 사용 가능한 도구를 나열해요
3. 쿼리 처리
  • 대화 컨텍스트를 유지해요
  • Claude의 응답과 도구 호출을 처리해요
  • Claude와 도구 사이의 메시지 흐름을 관리해요
  • 결과를 일관된 응답으로 결합해요
4. 대화형 인터페이스
  • 간단한 명령줄 인터페이스를 제공해요
  • 사용자 입력을 처리하고 응답을 표시해요
  • 기본 오류 처리를 포함해요
  • 우아하게 종료할 수 있어요
5. 리소스 관리
  • async with 블록을 나가면 연결을 끊고 서버 서브프로세스를 종료해요
  • 실패한 쿼리는 세션을 끝내지 않고 보고돼요
  • quit를 입력하거나 표준 입력을 닫으면 깔끔하게 종료돼요

흔한 커스터마이징 지점

  1. 도구 처리

    • 특정 도구 타입을 처리하도록 process_query() 수정
    • 도구 호출에 커스텀 오류 처리 추가
    • 도구별 응답 포맷 구현
  2. 응답 처리

    • 도구 결과 포맷 커스터마이즈
    • 응답 필터링·변환 추가
    • 커스텀 로깅 구현
  3. 사용자 인터페이스

    • GUI나 웹 인터페이스 추가
    • 풍부한 콘솔 출력 구현
    • 명령 히스토리·자동 완성 추가

클라이언트 실행하기

어떤 MCP 서버로든 클라이언트를 실행하려면:

uv run client.py path/to/server.py # python server
uv run client.py path/to/build/index.js # node server

서버 빠른 시작의 날씨 튜토리얼을 계속 진행 중이라면, 명령은 대략 python client.py .../quickstart-resources/weather-server-python/weather.py 이런 식일 거예요.

클라이언트는 다음을 수행해요.

  1. 지정된 서버에 연결
  2. 사용 가능한 도구 나열
  3. 다음을 할 수 있는 대화형 채팅 세션 시작:
    • 쿼리 입력
    • 도구 실행 보기
    • Claude의 응답 받기

동작 방식

쿼리를 제출하면:

  1. 클라이언트가 서버에서 사용 가능한 도구 목록을 가져와요
  2. 쿼리가 도구 설명과 함께 Claude로 전송돼요
  3. Claude가 어떤 도구를 사용할지(있다면) 결정해요
  4. 클라이언트가 요청된 도구 호출을 서버를 통해 실행해요
  5. 결과가 Claude로 다시 전송돼요
  6. Claude가 자연어 응답을 제공해요
  7. 응답이 사용자에게 표시돼요

모범 사례

  1. 오류 처리

    • 실패하는 도구가 예외를 던지길 기대하지 말고 result.is_error를 확인하세요
    • 의미 있는 오류 메시지를 제공하세요
    • 연결 문제를 우아하게 처리하세요
  2. 리소스 관리

    • async with 블록이 연결을 소유하게 하세요
    • 서버가 필요할 때까지 열어 두세요
    • 서버 연결 해제를 처리하세요
  3. 보안

    • API 키를 .env에 안전하게 저장하세요
    • 서버 응답을 검증하세요
    • 도구 권한에 주의하세요
  4. 도구 이름

    • 도구 이름은 여기에 지정된 형식에 따라 검증할 수 있어요
    • 도구 이름이 지정된 형식을 따르면 MCP 클라이언트의 검증을 실패하지 않아야 해요

문제 해결

서버 경로 문제
  • 서버 스크립트 경로가 올바른지 다시 확인하세요
  • 상대 경로가 안 되면 절대 경로를 사용하세요
  • Windows 사용자는 경로에 슬래시(/)나 이스케이프된 백슬래시(\)를 사용하세요
  • 서버 파일 확장자(.py for Python 또는 .js for Node.js)가 올바른지 확인하세요

올바른 경로 사용 예시:

# Relative path
uv run client.py ./server/weather.py

# Absolute path
uv run client.py /Users/username/projects/mcp-server/weather.py

# Windows path (either format works)
uv run client.py C:/projects/mcp-server/weather.py
uv run client.py C:\\projects\\mcp-server\\weather.py
응답 타이밍
  • 첫 응답은 최대 30초까지 걸릴 수 있어요
  • 이는 정상이며 다음 동안 발생해요:
    • 서버 초기화
    • Claude가 쿼리 처리
    • 도구 실행
  • 이후 응답은 보통 더 빠르요
  • 이 초기 대기 동안 프로세스를 방해하지 마세요
흔한 오류 메시지

만약 보인다면:

  • FileNotFoundError: 서버 경로를 확인하세요
  • Connection refused: 서버가 실행 중이고 경로가 올바른지 확인하세요
  • Tool execution failed: 도구의 필수 환경 변수가 설정되어 있는지 확인하세요
  • Timeout error: Client에서 read_timeout_seconds를 높이는 것을 고려하세요

TypeScript

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • Mac 또는 Windows 컴퓨터
  • Node.js 20 이상 설치
  • 최신 버전의 npm 설치
  • Anthropic API 키(Claude)

환경 설정

먼저 프로젝트를 만들고 설정할게요.

# Create project directory
mkdir mcp-client-typescript
cd mcp-client-typescript

# Initialize npm project
npm init -y

# Install dependencies
npm install @anthropic-ai/sdk @modelcontextprotocol/client dotenv

# Install dev dependencies
npm install -D @types/node typescript

# Create source file
touch index.ts
# Create project directory
md mcp-client-typescript
cd mcp-client-typescript

# Initialize npm project
npm init -y

# Install dependencies
npm install @anthropic-ai/sdk @modelcontextprotocol/client dotenv

# Install dev dependencies
npm install -D @types/node typescript

# Create source file
new-item index.ts

package.json을 업데이트해 type: "module"과 빌드 스크립트를 설정하세요.

{
  "type": "module",
  "scripts": {
    "build": "tsc && chmod 755 build/index.js"
  }
}

프로젝트 루트에 tsconfig.json을 만드세요.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "types": ["node"],
    "outDir": "./build",
    "rootDir": "./",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["index.ts"],
  "exclude": ["node_modules"]
}

API 키 설정

Anthropic Console에서 Anthropic API 키가 필요해요.

API 키를 저장할 .env 파일을 만들게요.

echo "ANTHROPIC_API_KEY=<your key here>" > .env

.env를 .gitignore에 추가하세요.

echo ".env" >> .gitignore

ANTHROPIC_API_KEY를 안전하게 보관하세요!

클라이언트 만들기

기본 클라이언트 구조

먼저 index.ts에서 임포트를 설정하고 기본 클라이언트 클래스를 만들게요.

import { Anthropic } from "@anthropic-ai/sdk";
import {
  MessageParam,
  Tool,
} from "@anthropic-ai/sdk/resources/messages/messages.mjs";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import readline from "readline/promises";
import dotenv from "dotenv";

dotenv.config();

const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (!ANTHROPIC_API_KEY) {
  throw new Error("ANTHROPIC_API_KEY is not set");
}

class MCPClient {
  private mcp: Client;
  private anthropic: Anthropic;
  private transport: StdioClientTransport | null = null;
  private tools: Tool[] = [];

  constructor() {
    this.anthropic = new Anthropic({
      apiKey: ANTHRO...KEY,
    });
    this.mcp = new Client({ name: "mcp-client-cli", version: "1.0.0" });
  }
  // methods will go here
}
서버 연결 관리

다음으로 MCP 서버에 연결하는 메서드를 구현할게요.

async connectToServer(serverScriptPath: string) {
  try {
    const isJs = serverScriptPath.endsWith(".js");
    const isPy = serverScriptPath.endsWith(".py");
    if (!isJs && !isPy) {
      throw new Error("Server script must be a .js or .py file");
    }
    const command = isPy
      ? process.platform === "win32"
        ? "python"
        : "python3"
      : process.execPath;

    this.transport = new StdioClientTransport({
      command,
      args: [serverScriptPath],
    });
    await this.mcp.connect(this.transport);

    const toolsResult = await this.mcp.listTools();
    this.tools = toolsResult.tools.map((tool) => {
      return {
        name: tool.name,
        description: tool.description,
        input_schema: tool.inputSchema,
      };
    });
    console.log(
      "Connected to server with tools:",
      this.tools.map(({ name }) => name)
    );
  } catch (e) {
    console.log("Failed to connect to MCP server: ", e);
    throw e;
  }
}
쿼리 처리 로직

이제 쿼리 처리와 도구 호출을 담당하는 핵심 기능을 추가할게요.

async processQuery(query: string) {
  const messages: MessageParam[] = [
    {
      role: "user",
      content: query,
    },
  ];

  const response = await this.anthropic.messages.create({
    model: "claude-opus-5",
    max_tokens: 1000,
    messages,
    tools: this.tools,
  });

  const finalText = [];

  for (const content of response.content) {
    if (content.type === "text") {
      finalText.push(content.text);
    } else if (content.type === "tool_use") {
      const toolName = content.name;
      const toolArgs = content.input as { [x: string]: unknown } | undefined;

      const result = await this.mcp.callTool({
        name: toolName,
        arguments: toolArgs,
      });
      finalText.push(
        `[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`
      );

      messages.push({
        role: "user",
        content: result.content
          .filter((block) => block.type === "text")
          .map((block) => block.text)
          .join("\n"),
      });

      const response = await this.anthropic.messages.create({
        model: "claude-opus-5",
        max_tokens: 1000,
        messages,
      });

      finalText.push(
        response.content[0].type === "text" ? response.content[0].text : ""
      );
    }
  }

  return finalText.join("\n");
}
대화형 채팅 인터페이스

이제 채팅 루프와 정리 기능을 추가할게요.

async chatLoop() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  try {
    console.log("\nMCP Client Started!");
    console.log("Type your queries or 'quit' to exit.");

    while (true) {
      const message = await rl.question("\nQuery: ");
      if (message.toLowerCase() === "quit") {
        break;
      }
      const response = await this.processQuery(message);
      console.log("\n" + response);
    }
  } finally {
    rl.close();
  }
}

async cleanup() {
  await this.mcp.close();
}
메인 진입점

마지막으로 메인 실행 로직을 추가할게요.

async function main() {
  if (process.argv.length < 3) {
    console.log("Usage: node index.ts <path_to_server_script>");
    return;
  }
  const mcpClient = new MCPClient();
  try {
    await mcpClient.connectToServer(process.argv[2]);
    await mcpClient.chatLoop();
  } catch (e) {
    console.error("Error:", e);
    await mcpClient.cleanup();
    process.exit(1);
  } finally {
    await mcpClient.cleanup();
    process.exit(0);
  }
}

main();

클라이언트 실행하기

어떤 MCP 서버로든 클라이언트를 실행하려면:

# Build TypeScript
npm run build

# Run the client
node build/index.js path/to/server.py # python server
node build/index.js path/to/build/index.js # node server

서버 빠른 시작의 날씨 튜토리얼을 계속 진행 중이라면, 명령은 대략 node build/index.js .../quickstart-resources/weather-server-typescript/build/index.js 이런 식일 거예요.

클라이언트는 다음을 수행해요:

  1. 지정된 서버에 연결
  2. 사용 가능한 도구 나열
  3. 다음을 할 수 있는 대화형 채팅 세션 시작:
    • 쿼리 입력
    • 도구 실행 보기
    • Claude의 응답 받기

동작 방식

쿼리를 제출하면:

  1. 클라이언트가 서버에서 사용 가능한 도구 목록을 가져와요
  2. 쿼리가 도구 설명과 함께 Claude로 전송돼요
  3. Claude가 어떤 도구를 사용할지(있다면) 결정해요
  4. 클라이언트가 요청된 도구 호출을 서버를 통해 실행해요
  5. 결과가 Claude로 다시 전송돼요
  6. Claude가 자연어 응답을 제공해요
  7. 응답이 사용자에게 표시돼요

모범 사례

  1. 오류 처리

    • 더 나은 오류 감지를 위해 TypeScript의 타입 시스템을 사용하세요
    • try-catch 블록으로 도구 호출을 감싸세요
    • 의미 있는 오류 메시지를 제공하세요
    • 연결 문제를 우아하게 처리하세요
  2. 보안

    • API 키를 .env에 안전하게 저장하세요
    • 서버 응답을 검증하세요
    • 도구 권한에 주의하세요

문제 해결

서버 경로 문제
  • 서버 스크립트 경로가 올바른지 다시 확인하세요
  • 상대 경로가 안 되면 절대 경로를 사용하세요
  • Windows 사용자는 경로에 슬래시(/)나 이스케이프된 백슬래시(\)를 사용하세요
  • 서버 파일 확장자(.js for Node.js 또는 .py for Python)가 올바른지 확인하세요

올바른 경로 사용 예시:

# Relative path
node build/index.js ./server/build/index.js

# Absolute path
node build/index.js /Users/username/projects/mcp-server/build/index.js

# Windows path (either format works)
node build/index.js C:/projects/mcp-server/build/index.js
node build/index.js C:\\projects\\mcp-server\\build\\index.js
응답 타이밍
  • 첫 응답은 최대 30초까지 걸릴 수 있어요
  • 이는 정상이며 다음 동안 발생해요:
    • 서버 초기화
    • Claude가 쿼리 처리
    • 도구 실행
  • 이후 응답은 보통 더 빠르요
  • 이 초기 대기 동안 프로세스를 방해하지 마세요
흔한 오류 메시지

만약 보인다면:

  • Error: Cannot find module: 빌드 폴더를 확인하고 TypeScript 컴파일이 성공했는지 확인하세요
  • Connection refused: 서버가 실행 중이고 경로가 올바른지 확인하세요
  • Tool execution failed: 도구의 필수 환경 변수가 설정되어 있는지 확인하세요
  • ANTHROPIC_API_KEY is not set: .env 파일과 환경 변수를 확인하세요
  • TypeError: 도구 인수에 올바른 타입을 사용하고 있는지 확인하세요
  • BadRequestError: Anthropic API에 접근할 충분한 크레딧이 있는지 확인하세요

Java

이는 Spring AI MCP 자동 구성과 부트 스타터를 기반으로 한 빠른 시작 데모예요. 동기·비동기 MCP 클라이언트를 수동으로 만드는 방법은 Java SDK Client 문서를 참고하세요.

이 예시는 Spring AI의 Model Context Protocol(MCP)과 Brave Search MCP Server를 결합한 대화형 챗봇을 구축하는 방법을 보여 줘요. 이 애플리케이션은 Anthropic의 Claude AI 모델로 구동되는 대화형 인터페이스를 만들어, Brave Search를 통해 인터넷 검색을 수행하고 실시간 웹 데이터와 자연어 상호작용을 가능하게 합니다.

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • Java 17 이상
  • Maven 3.6+
  • npx 패키지 매니저
  • Anthropic API 키(Claude)
  • Brave Search API 키

환경 설정

  1. npx(Node Package eXecute) 설치: 먼저 npm을 설치한 다음 실행하세요.

    npm install -g npx
    
  2. 저장소 복제:

    git clone https://github.com/spring-projects/spring-ai-examples.git
    cd model-context-protocol/web-search/brave-chatbot
    
  3. API 키 설정:

    export ANTHROPIC_API_KEY='your-anthropic-api-key-here'
    export BRAVE_API_KEY='your-brave-api-key-here'
    
  4. 애플리케이션 빌드:

    ./mvnw clean install
    
  5. Maven으로 애플리케이션 실행:

    ./mvnw spring-boot:run
    

ANTHROPIC_API_KEY와 BRAVE_API_KEY 키를 안전하게 보관하세요!

동작 방식

이 애플리케이션은 여러 구성 요소를 통해 Spring AI를 Brave Search MCP 서버와 통합해요.

MCP 클라이언트 구성
  1. pom.xml의 필수 의존성:
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
  1. 애플리케이션 속성(application.yml):
spring:
  ai:
    mcp:
      client:
        enabled: true
        name: brave-search-client
        version: 1.0.0
        type: SYNC
        request-timeout: 20s
        stdio:
          root-change-notification: true
          servers-configuration: classpath:/mcp-servers-config.json
        toolcallback:
          enabled: true
    anthropic:
      api-key: ${ANTH...KEY}

이렇게 하면 spring-ai-starter-mcp-client가 활성화되어, 제공된 서버 구성에 따라 하나 이상의 McpClient를 만들어요. spring.ai.mcp.client.toolcallback.enabled=true 속성은 모든 MCP 도구를 spring ai 도구로 자동 등록하는 도구 콜백 메커니즘을 활성화합니다. 기본적으로 비활성화되어 있어요.

  1. MCP 서버 구성(mcp-servers-config.json):
{
  "mcpServers": {
    "brave-search": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-brave-search"],
      "env": {
        "BRAVE_API_KEY": "<PUT YOUR BRAVE API KEY>"
      }
    }
  }
}
채팅 구현

챗봇은 MCP 도구 통합과 함께 Spring AI의 ChatClient를 사용해 구현해요.

var chatClient = chatClientBuilder
    .defaultSystem("You are useful assistant, expert in AI and Java.")
    .defaultToolCallbacks((Object[]) mcpToolAdapter.toolCallbacks())
    .defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
    .build();

핵심 기능:

  • 자연어 이해에 Claude AI 모델 사용
  • 실시간 웹 검색 기능을 위해 MCP를 통한 Brave Search 통합
  • InMemoryChatMemory로 대화 메모리 유지
  • 대화형 명령줄 애플리케이션으로 실행
빌드와 실행
./mvnw clean install
java -jar ./target/ai-mcp-brave-chatbot-0.0.1-SNAPSHOT.jar

또는

./mvnw spring-boot:run

애플리케이션은 질문할 수 있는 대화형 채팅 세션을 시작합니다. 챗봇은 쿼리에 답하기 위해 인터넷에서 정보를 찾아야 할 때 Brave Search를 사용해요.

챗봇은 다음을 할 수 있어요.

  • 내장 지식으로 질문에 답하기
  • 필요할 때 Brave Search로 웹 검색 수행
  • 대화에서 이전 메시지의 컨텍스트 기억하기
  • 여러 소스의 정보를 결합해 포괄적 답변 제공
고급 구성

MCP 클라이언트는 추가 구성 옵션을 지원해요.

  • McpClientCustomizer<McpClient.SyncSpec> 또는 McpClientCustomizer<McpClient.AsyncSpec> 빈을 통한 클라이언트 커스터마이즈
  • 여러 전송 타입을 가진 여러 클라이언트: STDIO와 Streamable HTTP
  • Spring AI의 도구 실행 프레임워크와의 통합
  • 자동 클라이언트 초기화와 수명주기 관리

Streamable HTTP로 원격 MCP 서버에 연결하려면 연결 URL을 구성하세요.

spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:8080

WebFlux 기반 애플리케이션은 대신 WebFlux 스타터를 사용할 수 있어요.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client-webflux</artifactId>
</dependency>

이는 유사한 기능을 제공하지만 WebFlux 기반 Streamable HTTP 전송 구현을 사용하며, 프로덕션 배포에 권장됩니다.

Kotlin

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • JDK 11 이상
  • Anthropic API 키(Claude)

환경 설정

먼저 아직 설치하지 않았다면 java와 gradle을 설치할게요. java는 공식 Oracle JDK 웹사이트에서 다운로드할 수 있어요. java 설치를 확인하세요.

java --version

이제 프로젝트를 만들고 설정할게요.

# Create a new directory for our project
mkdir kotlin-mcp-client
cd kotlin-mcp-client

# Initialize a new kotlin project
gradle init
# Create a new directory for our project
md kotlin-mcp-client
cd kotlin-mcp-client
# Initialize a new kotlin project
gradle init

gradle init을 실행한 후 프로젝트 유형으로 Application, 프로그래밍 언어로 Kotlin을 선택하세요.

대안으로 IntelliJ IDEA 프로젝트 마법사로 Kotlin 애플리케이션을 만들 수도 있어요.

프로젝트를 만든 후 build.gradle.kts 내용을 다음으로 바꾸세요.

// Check latest versions at https://github.com/modelcontextprotocol/kotlin-sdk/releases
val mcpVersion = "0.9.0"
val ktorVersion = "3.2.3"
val anthropicVersion = "2.15.0"
val slf4jVersion = "2.0.17"

plugins {
    kotlin("jvm") version "2.3.20"
    id("com.gradleup.shadow") version "8.3.9"
    application
}

application {
    mainClass.set("MainKt")
}

dependencies {
    implementation("io.modelcontextprotocol:kotlin-sdk:$mcpVersion")
    implementation("io.ktor:ktor-client-cio:$ktorVersion")
    implementation("com.anthropic:anthropic-java:$anthropicVersion")
    implementation("org.slf4j:slf4j-simple:$slf4jVersion")
}

모든 것이 올바르게 설정되었는지 확인하세요.

./gradlew build

API 키 설정

Anthropic Console에서 Anthropic API 키가 필요해요.

API 키를 설정하세요.

export ANTHROPIC_API_KEY='your-anthropic-api-key-here'

ANTHROPIC_API_KEY를 안전하게 보관하세요!

클라이언트 만들기

기본 클라이언트 구조

먼저 기본 클라이언트 클래스를 만들게요.

class MCPClient(apiKey: *** : AutoCloseable {
    private val anthropic = AnthropicOkHttpClient.builder()
        .apiKey(apiKey)
        .build()

  private val mcp: Client = Client(
        clientInfo = Implementation(name = "mcp-client-cli", version = "1.0.0")
  )
    private var serverProcess: Process? = null
    private lateinit var tools: List<ToolUnion>

    // methods will go here

    override fun close() {
        runBlocking {
            mcp.close()
        }
        serverProcess?.destroy()
        anthropic.close()
    }
}
서버 연결 관리

다음으로 MCP 서버에 연결하는 메서드를 구현할게요.

suspend fun connectToServer(serverScriptPath: String) {
    val command = buildList {
        when (serverScriptPath.substringAfterLast(".")) {
            "js" -> add("node")
            "py" -> add(if (System.getProperty("os.name").lowercase().contains("win")) "python" else "python3")
            "jar" -> addAll(listOf("java", "-jar"))
            else -> throw IllegalArgumentException("Server script must be a .js, .py or .jar file")
        }
        add(serverScriptPath)
    }

    val process = ProcessBuilder(command).start()
    serverProcess = process

    val transport = StdioClientTransport(
        input = process.inputStream.asSource().buffered(),
        output = process.outputStream.asSink().buffered(),
    )

    mcp.connect(transport)

    val toolsResult = mcp.listTools()
    tools = toolsResult.tools.map { tool ->
        ToolUnion.ofTool(
            Tool.builder()
                .name(tool.name)
                .description(tool.description ?: "")
                .inputSchema(
                    Tool.InputSchema.builder()
                        .type(JsonValue.from(tool.inputSchema.type))
                        .properties(tool.inputSchema.properties?.toJsonValue() ?: EmptyJsonObject.toJsonValue())
                        .putAdditionalProperty("required", JsonValue.from(tool.inputSchema.required))
                        .build(),
                )
                .build(),
        )
    }
    println("Connected to server with tools: ${tools.joinToString(", ") { it.tool().get().name() }}")
}
JsonObject.toJsonValue() 헬퍼

이 헬퍼는 Jackson을 사용해 kotlinx.serialization JsonObject를 Anthropic SDK JsonValue로 변환해요.

private fun JsonObject.toJsonValue(): JsonValue {
    val mapper = ObjectMapper()
    val node = mapper.readTree(this.toString())
    return JsonValue.fromJsonNode(node)
}
쿼리 처리 로직

이제 쿼리 처리와 도구 호출을 담당하는 핵심 기능을 추가할게요.

suspend fun processQuery(query: String): String {
    val messages = mutableListOf(
        MessageParam.builder()
            .role(MessageParam.Role.USER)
            .content(query)
            .build(),
    )

    val response = anthropic.messages().create(
        MessageCreateParams.builder()
            .model("claude-opus-5")
            .maxTokens(1024)
            .messages(messages)
            .tools(tools)
            .build(),
    )

    val finalText = mutableListOf<String>()
    response.content().forEach { content ->
        when {
            content.isText() -> finalText.add(content.text().get().text())

            content.isToolUse() -> {
                val toolName = content.toolUse().get().name()
                val toolArgs =
                    content.toolUse().get()._input().convert(object : TypeReference<Map<String, JsonValue>>() {})

                val result = mcp.callTool(
                    name = toolName,
                    arguments = toolArgs ?: emptyMap(),
                )
                finalText.add("[Calling tool $toolName with args $toolArgs]")

                messages.add(
                    MessageParam.builder()
                        .role(MessageParam.Role.USER)
                        .content(
                            result.content
                                .filterIsInstance<TextContent>()
                                .joinToString("\n") { it.text }
                        )
                        .build(),
                )

                val aiResponse = anthropic.messages().create(
                    MessageCreateParams.builder()
                        .model("claude-opus-5")
                        .maxTokens(1024)
                        .messages(messages)
                        .build(),
                )

                finalText.add(aiResponse.content().first().text().get().text())
            }
        }
    }

    return finalText.joinToString("\n")
}
대화형 채팅

채팅 루프를 추가할게요.

suspend fun chatLoop() {
    println("\nMCP Client Started!")
    println("Type your queries or 'quit' to exit.")

    while (true) {
        print("\nQuery: ")
        val message = readlnOrNull() ?: break
        if (message.trim().lowercase() == "quit") break

        try {
            val response = processQuery(message)
            println("\n$response")
        } catch (e: Exception) {
            println("\nError: ${e.message}")
        }
    }
}
메인 진입점

마지막으로 메인 실행 함수를 추가할게요.

fun main(args: Array<String>) = runBlocking {
    require(args.isNotEmpty()) { "Usage: java -jar <path> <path_to_server_script>" }

    val apiKey = System.getenv("ANTHROPIC_API_KEY")
    require(!apiKey.isNullOrBlank()) { "ANTHROPIC_API_KEY environment variable is not set" }

    val client = MCPClient(apiKey)
    client.use {
        client.connectToServer(args.first())
        client.chatLoop()
    }
}

클라이언트 실행하기

어떤 MCP 서버로든 클라이언트를 실행하려면:

./gradlew build

# Run the client
java -jar build/libs/kotlin-mcp-client-0.1.0-all.jar path/to/server.jar # JVM server
java -jar build/libs/kotlin-mcp-client-0.1.0-all.jar path/to/server.py  # Python server
java -jar build/libs/kotlin-mcp-client-0.1.0-all.jar path/to/build/index.js # Node server

Gradle로 직접 실행할 수도 있어요.

./gradlew run --args="path/to/server.jar"

서버 빠른 시작의 날씨 튜토리얼을 계속 진행 중이라면, 명령은 대략 java -jar build/libs/kotlin-mcp-client-0.1.0-all.jar .../samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar 이런 식일 거예요.

클라이언트는 다음을 수행해요.

  1. 지정된 서버에 연결
  2. 사용 가능한 도구 나열
  3. 다음을 할 수 있는 대화형 채팅 세션 시작:
    • 쿼리 입력
    • 도구 실행 보기
    • Claude의 응답 받기

동작 방식

다음은 높은 수준의 워크플로 스키마예요.

---
config:
    theme: neutral
---
sequenceDiagram
    actor User
    participant Client
    participant Claude
    participant MCP_Server as MCP Server
    participant Tools

    User->>Client: Send query
    Client<<->>MCP_Server: Get available tools
    Client->>Claude: Send query with tool descriptions
    Claude-->>Client: Decide tool execution
    Client->>MCP_Server: Request tool execution
    MCP_Server->>Tools: Execute chosen tools
    Tools-->>MCP_Server: Return results
    MCP_Server-->>Client: Send results
    Client->>Claude: Send tool results
    Claude-->>Client: Provide final response
    Client-->>User: Display response

쿼리를 제출하면:

  1. 클라이언트가 서버에서 사용 가능한 도구 목록을 가져와요
  2. 쿼리가 도구 설명과 함께 Claude로 전송돼요
  3. Claude가 어떤 도구를 사용할지(있다면) 결정해요
  4. 클라이언트가 요청된 도구 호출을 서버를 통해 실행해요
  5. 결과가 Claude로 다시 전송돼요
  6. Claude가 자연어 응답을 제공해요
  7. 응답이 사용자에게 표시돼요

모범 사례

  1. 오류 처리

    • Kotlin의 타입 시스템을 활용해 오류를 명시적으로 모델링하세요
    • 예외가 가능한 외부 도구·API 호출을 try-catch 블록으로 감싸세요
    • 명확하고 의미 있는 오류 메시지를 제공하세요
    • 네트워크 타임아웃과 연결 문제를 우아하게 처리하세요
  2. 보안

    • API 키와 비밀을 local.properties, 환경 변수, 또는 비밀 매니저에 안전하게 저장하세요
    • 예상치 못한 안전하지 않은 데이터 사용을 피하려면 모든 외부 응답을 검증하세요
    • 도구 사용 시 권한과 신뢰 경계에 주의하세요
  3. 환경

    • ANTHROPIC_API_KEY를 하드코딩하지 말고 환경 변수로 설정하세요
    • 로컬 개발에는 적절한 .gitignore 규칙과 함께 .env 파일을 사용하세요

문제 해결

서버 경로 문제
  • 서버 스크립트 경로가 올바른지 다시 확인하세요
  • 상대 경로가 안 되면 절대 경로를 사용하세요
  • Windows 사용자는 경로에 슬래시(/)나 이스케이프된 백슬래시(\)를 사용하세요
  • 필요한 런타임이 설치되어 있는지 확인하세요(java for Java, npm for Node.js, 또는 uv for Python)
  • 서버 파일 확장자(.jar for Java, .js for Node.js, .py for Python)가 올바른지 확인하세요

올바른 경로 사용 예시:

# Relative path
java -jar build/libs/client.jar ./server/build/libs/server.jar

# Absolute path
java -jar build/libs/client.jar /Users/username/projects/mcp-server/build/libs/server.jar

# Windows path (either format works)
java -jar build/libs/client.jar C:/projects/mcp-server/build/libs/server.jar
java -jar build/libs/client.jar C:\\projects\\mcp-server\\build\\libs\\server.jar
빌드 문제
  • 모든 의존성이 있는 shadow JAR을 만들려면 ./gradlew build 또는 ./gradlew shadowJar를 사용하세요(./gradlew jar 아님)
  • JDK 버전 오류가 나면 설치된 JDK 버전이 build.gradle.kts의 jvmToolchain 설정과 일치하거나 그 이상인지 확인하세요
응답 타이밍
  • 첫 응답은 최대 30초까지 걸릴 수 있어요
  • 이는 정상이며 다음 동안 발생해요:
    • 서버 초기화
    • Claude가 쿼리 처리
    • 도구 실행
  • 이후 응답은 보통 더 빠르요
  • 이 초기 대기 동안 프로세스를 방해하지 마세요
흔한 오류 메시지

만약 보인다면:

  • Connection refused: 서버가 실행 중이고 경로가 올바른지 확인하세요
  • Tool execution failed: 도구의 필수 환경 변수가 설정되어 있는지 확인하세요
  • ANTHROPIC_API_KEY is not set: 환경 변수를 확인하세요

C#

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • .NET 8.0 이상
  • Anthropic API 키(Claude)
  • Windows, Linux, 또는 macOS

환경 설정

먼저 새 .NET 프로젝트를 만드세요.

dotnet new console -n QuickstartClient
cd QuickstartClient

그런 다음 프로젝트에 필수 의존성을 추가하세요.

dotnet add package ModelContextProtocol --prerelease
dotnet add package Anthropic.SDK
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.AI

API 키 설정

Anthropic Console에서 Anthropic API 키가 필요해요.

dotnet user-secrets init
dotnet user-secrets set "ANTHROPIC_API_KEY" "<your key here>"

클라이언트 만들기

기본 클라이언트 구조

먼저 Program.cs 파일에 기본 클라이언트 클래스를 설정해 보세요.

using Anthropic.SDK;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Transport;

var builder = Host.CreateApplicationBuilder(args);

builder.Configuration
    .AddEnvironmentVariables()
    .AddUserSecrets<Program>();

이것은 사용자 시크릿에서 API 키를 읽을 수 있는 .NET 콘솔 애플리케이션의 시작을 만들어요.

다음으로 MCP 클라이언트를 설정할게요.

var (command, arguments) = GetCommandAndArguments(args);

var clientTransport = new StdioClientTransport(new()
{
    Name = "Demo Server",
    Command = command,
    Arguments = arguments,
});

await using var mcpClient = await McpClient.CreateAsync(clientTransport);

var tools = await mcpClient.ListToolsAsync();
foreach (var tool in tools)
{
    Console.WriteLine($"Connected to server with tools: {tool.Name}");
}

Program.cs 파일 끝에 이 함수를 추가하세요.

static (string command, string[] arguments) GetCommandAndArguments(string[] args)
{
    return args switch
    {
        [var script] when script.EndsWith(".py") => ("python", args),
        [var script] when script.EndsWith(".js") => ("node", args),
        [var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", ["run", "--project", script, "--no-build"]),
        _ => throw new NotSupportedException("An unsupported server script was provided. Supported scripts are .py, .js, or .csproj")
    };
}

이것은 명령줄 인수로 제공된 서버에 연결할 MCP 클라이언트를 만듭니다. 그런 다음 연결된 서버에서 사용 가능한 도구를 나열해요.

쿼리 처리 로직

이제 쿼리 처리와 도구 호출을 담당하는 핵심 기능을 추가할게요.

using var anthropicClient = new AnthropicClient(new APIAuthentication(builder.Configuration["ANTHROPIC_API_KEY"]))
    .Messages
    .AsBuilder()
    .UseFunctionInvocation()
    .Build();

var options = new ChatOptions
{
    MaxOutputTokens = 1000,
    ModelId = "claude-opus-5",
    Tools = [.. tools]
};

Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("MCP Client Started!");
Console.ResetColor();

PromptForInput();
while(Console.ReadLine() is string query && !"exit".Equals(query, StringComparison.OrdinalIgnoreCase))
{
    if (string.IsNullOrWhiteSpace(query))
    {
        PromptForInput();
        continue;
    }

    await foreach (var message in anthropicClient.GetStreamingResponseAsync(query, options))
    {
        Console.Write(message);
    }
    Console.WriteLine();

    PromptForInput();
}

static void PromptForInput()
{
    Console.WriteLine("Enter a command (or 'exit' to quit):");
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.Write("> ");
    Console.ResetColor();
}

핵심 구성 요소 설명

1. 클라이언트 초기화
  • 클라이언트는 McpClient.CreateAsync()로 초기화되며, 전송 타입과 서버 실행 명령을 설정해요.
2. 서버 연결
  • Python, Node.js, .NET 서버를 지원해요.
  • 인수에 지정된 명령으로 서버를 시작해요.
  • 서버와의 통신에 stdio를 사용하도록 구성해요.
  • 세션과 사용 가능한 도구를 초기화해요.
3. 쿼리 처리
  • 채팅 클라이언트에 Microsoft.Extensions.AI를 활용해요.
  • IChatClient가 자동 도구(함수) 호출을 사용하도록 구성해요.
  • 클라이언트가 사용자 입력을 읽어 서버로 보내요.
  • 서버가 쿼리를 처리하고 응답을 반환해요.
  • 응답이 사용자에게 표시돼요.

클라이언트 실행하기

어떤 MCP 서버로든 클라이언트를 실행하려면:

dotnet run -- path/to/server.csproj # dotnet server
dotnet run -- path/to/server.py # python server
dotnet run -- path/to/server.js # node server

서버 빠른 시작의 날씨 튜토리얼을 계속 진행 중이라면, 명령은 대략 dotnet run -- path/to/QuickstartWeatherServer 이런 식일 거예요.

클라이언트는 다음을 수행해요.

  1. 지정된 서버에 연결
  2. 사용 가능한 도구 나열
  3. 다음을 할 수 있는 대화형 채팅 세션 시작:
    • 쿼리 입력
    • 도구 실행 보기
    • Claude의 응답 받기
  4. 끝나면 세션 종료

Ruby

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • Mac 또는 Windows 컴퓨터
  • Ruby 3.2.0 이상 설치(Anthropic SDK에 필요)
  • Anthropic API 키(Claude)

환경 설정

먼저 새 Ruby 프로젝트를 만들게요.

# Create project directory
mkdir mcp-client
cd mcp-client

# Create a Gemfile
bundle init

# Add required dependencies
bundle add anthropic base64 dotenv mcp

# Create our main file
touch client.rb
# Create project directory
mkdir mcp-client
cd mcp-client

# Create a Gemfile
bundle init

# Add required dependencies
bundle add anthropic base64 dotenv mcp

# Create our main file
new-item client.rb

API 키 설정

Anthropic Console에서 Anthropic API 키가 필요해요.

API 키를 저장할 .env 파일을 만들게요.

echo "ANTHROPIC_API_KEY=your-api-key-goes-here" > .env

.env를 .gitignore에 추가하세요.

echo ".env" >> .gitignore

ANTHROPIC_API_KEY를 안전하게 보관하세요!

클라이언트 만들기

기본 클라이언트 구조

먼저 require를 설정하고 기본 클라이언트 클래스를 만들게요.

require "anthropic"
require "dotenv/load"
require "json"
require "mcp"

class MCPClient
  ANTHROPIC_MODEL = "claude-opus-5"

  def initialize
    @mcp_client = nil
    @transport = nil
    @anthropic_client = nil
  end

  # methods will go here
end
서버 연결 관리

다음으로 MCP 서버에 연결하는 메서드를 구현할게요.

def connect_to_server(server_script_path)
  command = case File.extname(server_script_path)
  when ".rb"
    "ruby"
  when ".py"
    "python3"
  when ".js"
    "node"
  else
    raise ArgumentError, "Server script must be a .rb, .py, or .js file."
  end

  @transport = MCP::Client::Stdio.new(command: command, args: [server_script_path])
  @mcp_client = MCP::Client.new(transport: @transport)
  @mcp_client.connect

  tool_names = @mcp_client.tools.map(&:name)
  puts "\nConnected to server with tools: #{tool_names}"
end
쿼리 처리 로직

이제 쿼리 처리와 도구 호출을 담당하는 핵심 기능을 추가할게요.

private

def process_query(query)
  messages = [{ role: "user", content: query }]

  available_tools = @mcp_client.tools.map do |tool|
    { name: tool.name, description: tool.description, input_schema: tool.input_schema }
  end

  # Initial Claude API call.
  response = chat(messages, tools: available_tools)

  # Process response and handle tool calls.
  if response.content.any?(Anthropic::Models::ToolUseBlock)
    assistant_content = response.content.filter_map do |content_block|
      case content_block
      when Anthropic::Models::TextBlock
        { type: "text", text: content_block.text }
      when Anthropic::Models::ToolUseBlock
        { type: "tool_use", id: content_block.id, name: content_block.name, input: content_block.input }
      end
    end
    messages << { role: "assistant", content: assistant_content }
  end

  response.content.each_with_object([]) do |content, response_parts|
    case content
    when Anthropic::Models::TextBlock
      response_parts << content.text
    when Anthropic::Models::ToolUseBlock
      # Execute tool call via MCP.
      result = @mcp_client.call_tool(name: content.name, arguments: content.input)
      response_parts << "[Calling tool #{content.name} with args #{content.input.to_json}]"

      tool_result_content = result.dig("result", "content")
      result_text = if tool_result_content.is_a?(Array)
        tool_result_content.filter_map { |content_item| content_item["text"] }.join("\n")
      else
        tool_result_content.to_s
      end

      messages << {
        role: "user",
        content: [{
          type: "tool_result",
          tool_use_id: content.id,
          content: result_text
        }]
      }

      # Get next response from Claude.
      response = chat(messages)

      response.content.each do |content_block|
        response_parts << content_block.text if content_block.is_a?(Anthropic::Models::TextBlock)
      end
    end
  end.join("\n")
end

def chat(messages, tools: nil)
  params = { model: ANTHROPIC_MODEL, max_tokens: 1000, messages: messages }
  params[:tools] = tools if tools

  anthropic_client.messages.create(**params)
end

def anthropic_client
  @anthropic_client ||= Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
end
대화형 채팅 인터페이스

이제 채팅 루프와 정리 기능을 추가할게요.

def chat_loop
  puts <<~MESSAGE
    MCP Client Started!
    Type your queries or 'quit' to exit.
  MESSAGE

  loop do
    print "\nQuery: "
    line = $stdin.gets
    break if line.nil?

    query = line.chomp.strip
    break if query.downcase == "quit"
    next if query.empty?

    begin
      response = process_query(query)
      puts "\n#{response}"
    rescue => e
      puts "\nError: #{e.message}"
    end
  end
end

def cleanup
  @transport&.close
end
메인 진입점

마지막으로 메인 실행 로직을 추가할게요.

if ARGV.empty?
  puts "Usage: ruby client.rb <path_to_server_script>"
  exit 1
end

client = MCPClient.new

begin
  client.connect_to_server(ARGV[0])

  api_key = ENV["ANTHROPIC_API_KEY"]
  if api_key.nil? || api_key.empty?
    puts <<~MESSAGE
      No ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:
        export ANTHROPIC_API_KEY=your-api-key-here
    MESSAGE
    exit
  end

  client.chat_loop
rescue => e
  puts "Error: #{e.message}"
  exit 1
ensure
  client.cleanup
end

완전한 client.rb 파일은 여기에서 확인할 수 있어요.

핵심 구성 요소 설명

1. 클라이언트 초기화
  • MCPClient 클래스는 지연 설정을 위해 nil 참조로 초기화해요.
  • Anthropic 클라이언트는 anthropic_client 메서드를 통해 지연 초기화돼요.
  • .env에서 환경 변수를 로드하는 데 dotenv를 사용해요.
2. 서버 연결
  • Ruby, Python, Node.js 서버를 지원해요.
  • 서버 스크립트 타입을 결정하는 데 File.extname을 사용해요.
  • stdio 전송에 MCP::Client::Stdio를 사용해요.
  • MCP 클라이언트를 초기화하고 사용 가능한 도구를 나열해요.
3. 쿼리 처리
  • MCP 도구를 Anthropic 도구 형식(name, description, input_schema)으로 매핑해요.
  • 패턴 매칭에 Anthropic::Models::TextBlock과 Anthropic::Models::ToolUseBlock을 사용해요.
  • 도구 호출을 반복하기 전에 어시스턴트 콘텐츠를 한 번 만들어요.
  • @mcp_client.call_tool로 도구 호출을 실행해요.
  • chat 헬퍼 메서드로 Anthropic API 호출을 감싸요.
  • result.dig("result", "content")로 도구 결과 콘텐츠를 추출해요.
  • 최종 응답을 위해 도구 결과를 Claude에 다시 전달해요.
4. 대화형 인터페이스
  • 간단한 명령줄 인터페이스를 제공해요.
  • 사용자 입력을 처리하고 응답을 표시해요.
  • 빈 쿼리를 건너뛰어요.
  • 기본 오류 처리를 포함해요.
5. 리소스 관리
  • begin...ensure로 전송을 제대로 정리해요.
  • 오류 처리용 최상위 rescue.
  • 서버 연결 후 API 키 검증.

클라이언트 실행하기

어떤 MCP 서버로든 클라이언트를 실행하려면:

bundle exec ruby client.rb path/to/server.rb # ruby server
bundle exec ruby client.rb path/to/server.py # python server
bundle exec ruby client.rb path/to/build/index.js # node server

서버 빠른 시작의 날씨 튜토리얼을 계속 진행 중이라면, 명령은 대략 bundle exec ruby client.rb /path/to/weather-server-ruby/weather.rb 이런 식일 거예요.

클라이언트는 다음을 수행해요.

  1. 지정된 서버에 연결
  2. 사용 가능한 도구 나열
  3. 다음을 할 수 있는 대화형 채팅 세션 시작:
    • 쿼리 입력
    • 도구 실행 보기
    • Claude의 응답 받기

동작 방식

쿼리를 제출하면:

  1. 클라이언트가 서버에서 사용 가능한 도구 목록을 가져와요
  2. 쿼리가 도구 설명과 함께 Claude로 전송돼요
  3. Claude가 어떤 도구를 사용할지(있다면) 결정해요
  4. 클라이언트가 요청된 도구 호출을 서버를 통해 실행해요
  5. 결과가 Claude로 다시 전송돼요
  6. Claude가 자연어 응답을 제공해요
  7. 응답이 사용자에게 표시돼요

모범 사례

  1. 오류 처리

    • 도구 호출을 begin...rescue 블록으로 감싸세요
    • 의미 있는 오류 메시지를 제공하세요
    • 연결 문제를 우아하게 처리하세요
  2. 리소스 관리

    • 끝나면 항상 전송을 닫으세요
    • 제대로 된 정리를 위해 begin...ensure를 사용하세요
    • 서버 연결 해제를 처리하세요
  3. 보안

    • API 키를 .env에 안전하게 저장하세요
    • 서버 응답을 검증하세요
    • 도구 권한에 주의하세요
  4. 도구 이름

    • 도구 이름은 여기에 지정된 형식에 따라 검증할 수 있어요

문제 해결

서버 경로 문제
  • 서버 스크립트 경로가 올바른지 다시 확인하세요
  • 상대 경로가 안 되면 절대 경로를 사용하세요
  • Windows 사용자는 경로에 슬래시(/)나 이스케이프된 백슬래시(\)를 사용하세요
  • 서버 파일 확장자(.py for Python, .js for Node.js, .rb for Ruby)가 올바른지 확인하세요

올바른 경로 사용 예시:

# Relative path
bundle exec ruby client.rb ./server/weather.rb

# Absolute path
bundle exec ruby client.rb /Users/username/projects/mcp-server/weather.rb

# Windows path (either format works)
bundle exec ruby client.rb C:/projects/mcp-server/weather.rb
bundle exec ruby client.rb C:\\projects\\mcp-server\\weather.rb
응답 타이밍
  • 첫 응답은 최대 30초까지 걸릴 수 있어요
  • 이는 정상이며 다음 동안 발생해요:
    • 서버 초기화
    • Claude가 쿼리 처리
    • 도구 실행
  • 이후 응답은 보통 더 빠르요
  • 이 초기 대기 동안 프로세스를 방해하지 마세요
흔한 오류 메시지

만약 보인다면:

  • Errno::ENOENT: 서버 경로를 확인하고 명령(ruby, python3, node)을 사용할 수 있는지 확인하세요
  • Connection refused: 서버가 실행 중이고 경로가 올바른지 확인하세요
  • Tool execution failed: 도구의 필수 환경 변수가 설정되어 있는지 확인하세요
  • Anthropic::Errors::AuthenticationError: .env 파일에 유효한 ANTHROPIC_API_KEY가 있는지 확인하세요

Rust

이 튜토리얼의 완전한 코드는 여기에서 확인할 수 있어요.

시스템 요구 사항

시작하기 전에 Linux 시스템이 다음 요구 사항을 충족하는지 확인하세요.

  • 최신 안정 버전의 Rust와 Cargo
  • Anthropic API 키(Claude)
  • 연결할 Python, Node.js 또는 실행 가능한 MCP 서버

환경 설정

먼저 새 Rust 프로젝트를 만들게요.

cargo new mcp-client-rust
cd mcp-client-rust

Cargo.toml 내용을 다음으로 바꾸세요.

[package]
name = "mcp-client-rust"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.100"
genai = "0.4.2"
rmcp = { version = "0.8.0", features = ["server", "client", "transport-io", "transport-child-process"] }
tokio = { version = "1.47.1", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde_json = "1.0.128"
dotenvy = "0.15.7"
reqwest = "0.12.23"

rmcp 크레이트는 Rust MCP SDK와 자식 프로세스 전송을 제공해요. 이 예시는 genai 크레이트를 사용해 Claude에 요청을 보내고 모델 요청에서 도구를 표현합니다.

API 키 설정

Anthropic Console에서 Anthropic API 키가 필요해요.

API 키를 저장할 .env 파일을 만들게요.

echo "ANTHROPIC_API_KEY=your-api-key-goes-here" > .env

.env를 .gitignore에 추가하세요.

echo ".env" >> .gitignore

ANTHROPIC_API_KEY를 안전하게 보관하세요!

클라이언트 만들기

src/main.rs를 열고 다음 섹션을 진행하면서 내용을 바꾸세요.

임포트와 클라이언트 구조

먼저 임포트, 모델 상수, 기본 클라이언트 구조를 추가해요.

use anyhow::{Context, Result, bail};
use genai::Client;
use genai::chat::{
    ChatMessage, ChatRequest, ChatResponse, ContentPart, Tool as GenaiTool, ToolResponse,
};
use rmcp::model::{CallToolRequestParam, Tool as McpTool};
use rmcp::service::{RoleClient, RunningService, ServiceExt};
use rmcp::transport::TokioChildProcess;
use serde_json::Value;
use tokio::io::{self, AsyncBufReadExt, BufReader};
use tokio::process::Command;

const MODEL_ANTHROPIC: &str = "claude-opus-5";

struct MCPClient {
    anthropic: Client,
    session: Option<RunningService<RoleClient, ()>>,
    tools: Vec<GenaiTool>,
}

클라이언트는 모델 API 클라이언트, 활성 MCP 세션, 연결된 서버가 광고하는 도구를 보관해요.

클라이언트 초기화

다음으로 MCP 세션이나 도구 없이 모델 클라이언트를 초기화하고 시작해요.

impl MCPClient {
    fn new() -> Result<Self> {
        Ok(MCPClient {
            anthropic: Client::default(),
            session: None,
            tools: Vec::new(),
        })
    }

    // Additional methods will go here.
}

genai::Client::default()는 요청을 보낼 때 ANTHROPIC_API_KEY 환경 변수를 읽어요.

서버 연결 관리

impl MCPClient 블록 안에 이 메서드를 추가하세요.

async fn connect_to_server(&mut self, server_args: &[String]) -> Result<()> {
    if self.session.is_some() {
        bail!("Client is already connected to a server");
    }

    let mut command = Command::new(&server_args[0]);
    command.args(&server_args[1..]);

    let process = TokioChildProcess::new(command)
        .with_context(|| format!("Failed to spawn server process for {:?}", server_args))?;

    let session = ().serve(process).await?;

    let rmcp_tools = session
        .list_all_tools()
        .await
        .context("Unable to list tools from server")?;

    let tool_names: Vec<String> = rmcp_tools
        .iter()
        .map(|tool| tool.name.to_string())
        .collect();

    println!("Connected to server with tools: {tool_names:?}");

    self.tools = convert_tools(&rmcp_tools);
    self.session = Some(session);
    Ok(())
}

이 메서드는:

  1. 명령줄에 제공된 명령과 인수로 서버를 자식 프로세스로 시작해요
  2. stdio 위에 MCP 세션을 수립해요
  3. 서버가 광고하는 모든 도구를 나열해요
  4. 그 도구들을 모델 요청에 사용되는 형식으로 변환해요
MCP 도구 변환

impl MCPClient 블록 밖에 이 함수를 추가하세요.

fn convert_tools(tools: &[McpTool]) -> Vec<GenaiTool> {
    tools
        .iter()
        .map(|tool| GenaiTool {
            name: tool.name.to_string(),
            description: tool.description.as_deref().map(str::to_string),
            schema: Some(Value::Object(tool.input_schema.as_ref().clone())),
            config: None,
        })
        .collect()
}

MCP과 모델 API는 비슷한 정보로 도구를 설명하지만 서로 다른 Rust 타입을 사용해요. convert_tools는 각 MCP 도구의 이름, 설명, 입력 스키마를 genai 도구 정의로 매핑합니다.

모델 요청 보내기

impl MCPClient 안에 이 헬퍼 메서드를 추가하세요.

async fn request_model(&self, chat_req: &ChatRequest) -> Result<ChatResponse> {
    let response = self
        .anthropic
        .exec_chat(MODEL_ANTHROPIC, chat_req.clone(), None)
        .await
        .context("Anthropic chat request failed")?;

    Ok(response)
}

이것은 모델 요청 처리를 한 곳에 두고, API 요청이 실패하면 유용한 컨텍스트를 추가해요.

쿼리 처리 로직

이제 impl MCPClient 안에 핵심 쿼리 처리 메서드를 추가하세요.

async fn process_query(&mut self, query: &str) -> Result<String> {
    let session = self
        .session
        .as_ref()
        .context("Client is not connected to any server")?;

    let mut messages = vec![ChatMessage::user(query)];
    let mut final_text = Vec::new();

    // Initial Claude API call with tools
    let mut chat_req = ChatRequest::new(messages.clone()).with_tools(self.tools.clone());
    let mut chat_rsp = self.request_model(&chat_req).await?;

    // Process response content - collect text and handle tool calls
    for text in chat_rsp.texts() {
        final_text.push(text.to_string());
    }

    let tool_calls = chat_rsp.tool_calls();
    if !tool_calls.is_empty() {
        // Append assistant's response to message history
        messages.push(ChatMessage::assistant(chat_rsp.content.clone()));

        // Execute each tool call and collect responses
        let mut tool_results = Vec::new();
        for tool_call in tool_calls {
            // Add information about the tool call to final text
            let tool_args_str = serde_json::to_string(&tool_call.fn_arguments)
                .unwrap_or_else(|_| "{}".to_string());

            final_text.push(format!(
                "[Calling tool {} with args {}]",
                tool_call.fn_name, tool_args_str
            ));

            // Query the MCP server
            let tool_result = session
                .call_tool(CallToolRequestParam {
                    name: tool_call.fn_name.clone().into(),
                    arguments: tool_call.fn_arguments.as_object().cloned(),
                })
                .await
                .with_context(|| format!("Tool call {} failed", tool_call.fn_name))?;

            let payload = serde_json::to_string(&tool_result)
                .context("Failed to serialize tool result")?;

            tool_results.push(ContentPart::ToolResponse(ToolResponse::new(
                tool_call.call_id.clone(),
                payload,
            )));
        }

        // Append tool responses to message history
        messages.push(ChatMessage::user(tool_results));

        // Build the next request and query model
        chat_req = ChatRequest::new(messages.clone());
        chat_rsp = self.request_model(&chat_req).await?;

        // Collect text from response
        for text in chat_rsp.texts() {
            final_text.push(text.to_string());
        }
    }

    Ok(final_text.join("\n"))
}

이 메서드는 먼저 사용자 쿼리와 사용 가능한 도구를 Claude에 보내요. Claude가 도구를 요청하면 클라이언트는 각 요청을 MCP 세션을 통해 실행하고, 결과를 Claude로 보내며, 최종 텍스트 응답을 수집합니다.

대화형 채팅 인터페이스

impl MCPClient 안에 대화형 터미널 루프를 추가하세요.

async fn chat_loop(&mut self) -> Result<()> {
    println!("\nMCP Client Started!");
    println!("Type your queries or 'quit' to exit.");

    let mut stdin = BufReader::new(io::stdin());
    let mut input = String::new();

    loop {
        print!("\nQuery: ");
        std::io::Write::flush(&mut std::io::stdout())?;

        input.clear();
        if stdin.read_line(&mut input).await? == 0 {
            break; // EOF
        }

        let query = input.trim();
        if query.eq_ignore_ascii_case("quit") {
            break;
        }
        if query.is_empty() {
            continue;
        }

        match self.process_query(query).await {
            Ok(response) => println!("\n{}", response),
            Err(err) => println!("\nError: {}", err),
        }
    }

    Ok(())
}

이 루프는 사용자가 quit를 입력하거나 표준 입력을 닫을 때까지 쿼리를 받아요. 쿼리 오류는 클라이언트를 종료하지 않고 출력됩니다.

정리

impl MCPClient 안에 이 메서드를 추가해 MCP 세션과 자식 프로세스를 중지하세요.

async fn cleanup(&mut self) -> Result<()> {
    if let Some(session) = self.session.take() {
        let _ = session.cancel().await;
    }
    Ok(())
}
메인 진입점

마지막으로 impl MCPClient 블록 밖에 비동기 진입점을 추가하세요.

#[tokio::main]
async fn main() -> Result<()> {
    dotenvy::dotenv().context("Failed to load env file")?;

    let mut args = std::env::args();
    let _ = args.next();
    let server_args: Vec<String> = args.collect();

    if server_args.is_empty() {
        eprintln!("Usage: cargo run -- <server_script_or_binary> [args...]");
        std::process::exit(1);
    }

    let mut client = MCPClient::new()?;

    let result = async {
        client.connect_to_server(&server_args).await?;
        client.chat_loop().await
    }
    .await;

    let cleanup_result = client.cleanup().await;

    result?;
    cleanup_result?;

    Ok(())
}

진입점은 .env를 로드하고, 남은 모든 명령줄 인수를 서버 명령으로 취급하며, 클라이언트를 연결하고 채팅 루프를 시작한 뒤, 종료 전에 정리가 실행되도록 보장해요.

완전한 파일 확인

클라이언트를 실행하기 전에 src/main.rs의 항목들이 올바른 스코프에 배치되었는지 확인하세요.

  • new, connect_to_server, process_query, request_model, chat_loop, cleanup은 단일 impl MCPClient 블록 안의 메서드예요.
  • main과 convert_tools는 impl MCPClient 블록 밖의 함수예요.

Rust는 이 항목들이 특정 순서로 나타날 것을 요구하지 않지만, 메서드와 자유 함수는 올바른 스코프에 배치되어야 해요. 파일을 완전한 src/main.rs 예시와 비교한 다음, 컴파일되는지 확인하세요.

cargo fmt --check
cargo check

클라이언트 실행하기

MCP 서버를 시작할 때 일반적으로 사용하는 명령 뒤에 cargo run --를 사용하세요.

# Python server
cargo run -- python path/to/server.py

# Node.js server
cargo run -- node path/to/build/index.js

# Executable server
cargo run -- path/to/server-binary

서버 명령 없이 cargo run만 실행하면 사용법 메시지를 출력하고 종료돼요.

서버 빠른 시작의 날씨 튜토리얼을 계속 진행 중이라면, 서버를 먼저 빌드한 다음 cargo run -- ../weather-server-rust/target/debug/weather와 비슷한 명령을 실행하세요.

클라이언트는 다음을 수행해요.

  1. 지정된 MCP 서버를 시작하고 연결
  2. 그 서버에서 사용 가능한 도구 나열
  3. 다음을 할 수 있는 대화형 채팅 세션 시작:
    • 쿼리 입력
    • 도구 실행 보기
    • Claude의 응답 받기

동작 방식

쿼리를 제출하면:

  1. 클라이언트가 쿼리와 서버의 사용 가능한 도구를 Claude로 보내요
  2. Claude가 어떤 도구를 사용할지(있다면) 결정해요
  3. 클라이언트가 요청된 도구를 MCP 세션을 통해 실행해요
  4. 도구 결과가 Claude로 다시 전송돼요
  5. Claude가 자연어 응답을 제공해요
  6. 응답이 터미널에 표시돼요

모범 사례

  1. 오류 처리

    • 프로세스, MCP, 모델 API, 직렬화 경계에서 오류에 컨텍스트를 추가하세요
    • 대화형 세션을 종료하지 않고 개별 쿼리 오류를 보고하세요
    • 실행하기 전에 서버 명령을 검증하세요
  2. 리소스 관리

    • 정리 중에는 항상 MCP 세션을 취소하세요
    • 연결이나 채팅 루프 작업이 실패해도 정리가 실행되도록 보장하세요
    • 세션이 활성화된 동안 두 번째 서버를 시작하지 마세요
  3. 보안

    • API 키를 .env에 안전하게 저장하세요
    • 모델 주도 호출을 허용하기 전에 서버가 노출하는 도구를 검토하세요
    • 신뢰하는 서버와 실행 파일 명령에만 연결하세요

문제 해결

서버 명령 문제

cargo run -- 다음의 인수는 완전한 명령을 형성해야 해요. 해석되는 서버 스크립트는 런타임이 필요합니다.

# Correct
cargo run -- python ./server/weather.py
cargo run -- node ./server/build/index.js

# Incorrect: a Python script is not necessarily executable by itself
cargo run -- ./server/weather.py

명령을 찾을 수 없으면 절대 경로를 사용하거나 PATH에서 사용 가능한지 확인하세요.

환경 파일 문제

Failed to load env file이 보이면 클라이언트를 실행하는 디렉터리에 .env가 있는지 확인하세요.

모델 요청이 API 키 누락을 보고하면 .env에 다음이 있는지 확인하세요.

ANTHROPIC_API_KEY=your-api-key-goes-here
도구와 응답 오류
  • Unable to list tools from server: 서버가 성공적으로 시작되고 stdio로 통신하는지 확인하세요
  • Tool call ... failed: 서버 도구의 필수 인수와 환경 변수를 확인하세요
  • Failed to serialize tool result: 서버의 응답에 지원되지 않거나 잘못된 콘텐츠가 있는지 검사하세요

다음 단계

  • 예시 서버 — 공식 MCP 서버와 구현 갤러리 살펴보기

더 알아보기 (Learn more)