AI

MCP(Model Context Protocol) 클라이언트 개발 가이드: LLM과 외부 도구 통합하기

jjambbong 2025. 4. 11. 10:09

MCP(Model Context Protocol) 클라이언트 개발 가이드: LLM과 외부 도구 통합하기

들어가며: MCP란 무엇인가?

최근 대규모 언어 모델(LLM)이 많은 애플리케이션에서 활용되고 있습니다. 그러나 이러한 모델들이 실제 데이터나 도구에 안전하게 접근하는 방법에 대한 표준이 없었습니다. 이 문제를 해결하기 위해 등장한 것이 바로 Model Context Protocol(MCP) 입니다.

MCP는 LLM 애플리케이션이 외부 데이터 소스, 도구 및 작업 흐름과 상호 작용할 수 있게 해주는 개방형 표준 프로토콜입니다. 쉽게 말해, LLM이 외부 세계와 안전하게 소통할 수 있는 다리 역할을 합니다.

저는 지난 며칠 동안 사내 개발 팀에서 MCP 기반 시스템을 구축하면서 다양한 시행착오를 겪었습니다. 특히 클라이언트 개발 부분에서는 문서가 상대적으로 부족해 어려움을 겪었죠. 이 글에서는 제가 얻은 인사이트를 바탕으로 MCP 클라이언트 개발에 대한 가이드를 제공하고자 합니다.

MCP 아키텍처 이해하기

MCP는 기본적으로 클라이언트-서버 아키텍처를 따릅니다. 여기서 중요한 세 가지 구성 요소는 다음과 같습니다:

  1. 호스트 애플리케이션: MCP 클라이언트를 내장하는 애플리케이션 (예: Claude Desktop, VS Code 등)
  2. MCP 클라이언트: 서버와 1:1 연결을 유지하고 통신을 담당하는 구성 요소
  3. MCP 서버: 외부 도구와 데이터 소스에 접근하는 기능을 제공하는 서버

여기서 주목해야 할 것은 클라이언트와 서버의 역할입니다. MCP 서버가 실제 기능(도구, 리소스, 프롬프트)을 제공하는 역할을 하고, MCP 클라이언트는 이러한 기능을 LLM에 연결해주는 중개자 역할을 합니다.

웹 개발에 비유하자면, MCP 서버는 RESTful API 서버와 유사하고, MCP 클라이언트는 이 API를 호출하는 프론트엔드 클라이언트와 유사합니다. 다만 MCP는 특별히 LLM과의 통합을 위해 설계되었다는 점이 다릅니다.

MCP 클라이언트의 역할

MCP 클라이언트는 다음과 같은 역할을 담당합니다:

  1. 서버에 연결하고 통신 세션을 관리
  2. 서버가 제공하는 기능(도구, 리소스, 프롬프트) 탐색
  3. LLM을 대신하여 서버의 도구 실행
  4. 리소스 읽기 요청 처리
  5. 프롬프트 가져오기 및 LLM에 제공
  6. 오류 처리 및 안전한 통신 보장

이제 실제로 MCP 클라이언트를 개발하는 방법을 알아보겠습니다. 여기서는 TypeScript SDK를 사용한 구현 방법을 중점적으로 다룰 것입니다.

개발 환경 설정하기

MCP 클라이언트 개발을 시작하기 전에, 필요한 도구와 라이브러리를 설치해야 합니다.

# 새 프로젝트 디렉토리 생성
mkdir mcp-client-project
cd mcp-client-project

# npm 초기화
npm init -y

# TypeScript SDK 및 의존성 설치
npm install @modelcontextprotocol/sdk typescript zod

TypeScript 구성을 위한 tsconfig.json 파일도 준비해주세요:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

이제 개발 환경이 준비되었습니다. src 디렉토리를 만들고 첫 번째 클라이언트 코드를 작성해봅시다.

첫 번째 MCP 클라이언트 구현하기

MCP 클라이언트의 기본 구조는 다음과 같습니다:

// src/index.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

async function main() {
  // 클라이언트 인스턴스 생성
  const client = new Client(
    { name: 'ExampleClient', version: '1.0.0' },
    { capabilities: { tools: {}, resources: {}, prompts: {} } }
  );

  try {
    // 트랜스포트 생성 (서버와 통신하는 방법)
    const transport = new StdioClientTransport({
      command: './server.js', // MCP 서버 실행 파일 경로
      args: []
    });

    // 서버에 연결
    console.log('서버에 연결 중...');
    await client.connect(transport);

    // 연결 초기화
    console.log('연결 초기화 중...');
    await client.initialize();
    console.log('연결이 성공적으로 설정되었습니다');

    // 서버 기능 검색
    const tools = await client.listTools();
    console.log('사용 가능한 도구:', tools.tools);

    // 도구 사용 예시
    if (tools.tools.some(tool => tool.name === 'echo')) {
      console.log('echo 도구 호출 중...');
      const result = await client.callTool('echo', { message: '안녕하세요!' });
      console.log('결과:', result.content[0].text);
    }

    // 연결 종료
    await client.close();
  } catch (error) {
    console.error('오류 발생:', error);
  }
}

main();

이 코드는 기본적인 MCP 클라이언트를 설정하고, 서버에 연결하며, 사용 가능한 도구를 나열합니다. 그리고 'echo' 도구가 있으면 호출합니다.

지난 프로젝트에서는 이와 같은 기본 코드로 시작했지만, 실제 프로덕션 환경에서는 여러 가지 문제가 발생했습니다. 연결 오류 처리, 재시도 로직, 타임아웃 관리 등 다양한 요소를 고려해야 했죠. 다음 섹션에서는 이런 실제 문제들을 해결하는 방법을 알아보겠습니다.

연결 및 인증 관리하기

실제 애플리케이션에서는 MCP 서버와의 연결을 안정적으로 관리하는 것이 중요합니다. 다음은 연결 관리를 위한 확장된 클래스입니다:

// src/connection-manager.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';

class ConnectionManager {
  private client: Client;
  private isConnected: boolean = false;
  private reconnectAttempts: number = 0;
  private maxReconnectAttempts: number = 5;

  constructor(clientName: string, clientVersion: string) {
    this.client = new Client(
      { name: clientName, version: clientVersion },
      { capabilities: { tools: {}, resources: {}, prompts: {} } }
    );
  }

  async connect(serverPath: string, args: string[] = []): Promise<boolean> {
    if (this.isConnected) {
      console.log('이미 연결되어 있습니다.');
      return true;
    }

    try {
      const transport = new StdioClientTransport({
        command: serverPath,
        args: args
      });

      await this.client.connect(transport);
      await this.client.initialize();
      
      this.isConnected = true;
      this.reconnectAttempts = 0;
      console.log('서버에 성공적으로 연결되었습니다.');
      
      return true;
    } catch (error) {
      console.error('연결 오류:', error);
      return await this.handleReconnect(serverPath, args);
    }
  }

  private async handleReconnect(serverPath: string, args: string[]): Promise<boolean> {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('최대 재연결 시도 횟수에 도달했습니다.');
      return false;
    }

    this.reconnectAttempts++;
    const delay = 1000 * Math.pow(2, this.reconnectAttempts - 1); // 지수 백오프
    
    console.log(`${delay}ms 후 재연결 시도... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
    
    return new Promise<boolean>(resolve => {
      setTimeout(async () => {
        resolve(await this.connect(serverPath, args));
      }, delay);
    });
  }

  getClient(): Client {
    return this.client;
  }

  async disconnect(): Promise<void> {
    if (!this.isConnected) {
      return;
    }

    try {
      await this.client.close();
      this.isConnected = false;
      console.log('서버 연결이 종료되었습니다.');
    } catch (error) {
      console.error('연결 종료 오류:', error);
    }
  }

  isClientConnected(): boolean {
    return this.isConnected;
  }
}

export { ConnectionManager };

이 ConnectionManager 클래스는 다음과 같은 기능을 제공합니다:

  1. 서버 연결 관리
  2. 연결 실패 시 지수 백오프를 사용한 재연결 시도
  3. 연결 상태 추적
  4. 연결 종료 처리

실제 프로젝트에서는 네트워크 불안정, 서버 다운타임 등 다양한 문제가 발생할 수 있으므로 이런 연결 관리 로직이 필수적입니다.

만약 HTTP/SSE 기반 원격 MCP 서버를 사용한다면, 인증도 고려해야 합니다:

// src/authenticated-connection.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';

class AuthenticatedConnection {
  private client: Client;
  private authToken: string;
  private serverUrl: string;

  constructor(clientName: string, clientVersion: string, serverUrl: string, authToken: string) {
    this.client = new Client(
      { name: clientName, version: clientVersion },
      { capabilities: { tools: {}, resources: {}, prompts: {} } }
    );
    this.serverUrl = serverUrl;
    this.authToken = authToken;
  }

  async connect(): Promise<boolean> {
    try {
      const transport = new SSEClientTransport(
        new URL(this.serverUrl)
      );
      
      // 인증 헤더 설정
      transport.setHeaders({
        'Authorization': `Bearer ${this.authToken}`
      });

      await this.client.connect(transport);
      await this.client.initialize();
      
      console.log('인증된 연결이 설정되었습니다.');
      return true;
    } catch (error) {
      console.error('인증된 연결 설정 실패:', error);
      return false;
    }
  }

  getClient(): Client {
    return this.client;
  }
}

export { AuthenticatedConnection };

이제 연결 및 인증 관리 방법을 알았으니, 다음으로 서버 기능을 활용하는 방법을 살펴보겠습니다.

서버 기능 탐색 및 활용하기

MCP 서버는 세 가지 주요 기능을 제공합니다: 도구(Tools), 리소스(Resources), 프롬프트(Prompts). 이제 이들을 어떻게 탐색하고 활용하는지 알아보겠습니다.

도구(Tools) 활용하기

도구는 서버가 제공하는 실행 가능한 함수입니다. 다음은 도구를 탐색하고 호출하는 방법입니다:

// src/tool-manager.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { z } from 'zod';

class ToolManager {
  private client: Client;
  private availableTools: any[] = [];

  constructor(client: Client) {
    this.client = client;
  }

  async discoverTools(): Promise<any[]> {
    try {
      const result = await this.client.listTools();
      this.availableTools = result.tools;
      return this.availableTools;
    } catch (error) {
      console.error('도구 검색 실패:', error);
      return [];
    }
  }

  async callTool(toolName: string, args: any): Promise<any> {
    try {
      // 도구가 존재하는지 확인
      const tool = this.availableTools.find(t => t.name === toolName);
      if (!tool) {
        throw new Error(`"${toolName}" 도구를 찾을 수 없습니다.`);
      }

      // 입력 스키마에 대해 args 검증
      // (실제 구현에서는 tool.inputSchema를 사용하여 Zod 스키마 생성)
      
      // 도구 호출
      const result = await this.client.callTool(toolName, args);
      
      // 도구 오류 확인
      if (result.isError) {
        throw new Error(`도구 오류: ${result.content[0].text}`);
      }
      
      return result.content;
    } catch (error) {
      console.error(`도구 호출 오류 (${toolName}):`, error);
      throw error;
    }
  }

  getToolByName(toolName: string): any {
    return this.availableTools.find(t => t.name === toolName);
  }

  getAllTools(): any[] {
    return this.availableTools;
  }
}

export { ToolManager };

이런 ToolManager 클래스를 사용하면 도구 검색, 검증, 호출을 효과적으로 관리할 수 있습니다.

지난 프로젝트에서는 도구 호출 시 오류 처리가 제대로 되지 않아 문제가 발생했습니다. 특히 도구가 예상치 못한 형식으로 응답을 반환할 때 애플리케이션이 크래시되는 경우가 있었죠. 이런 문제를 방지하기 위해 응답 검증과 정규화 로직을 추가했습니다:

// 도구 응답 정규화
function normalizeToolResponse(response: any): any {
  if (!response) {
    return { content: [], isError: true };
  }
  
  if (!response.content || !Array.isArray(response.content)) {
    return {
      content: [{ type: 'text', text: String(response) }],
      isError: !!response.isError
    };
  }
  
  // content 배열 아이템 정규화
  const normalizedContent = response.content.map((item: any) => {
    if (typeof item === 'string') {
      return { type: 'text', text: item };
    }
    if (!item.type) {
      return { type: 'text', text: JSON.stringify(item) };
    }
    return item;
  });
  
  return {
    content: normalizedContent,
    isError: !!response.isError
  };
}

리소스(Resources) 활용하기

리소스는 서버가 제공하는 데이터 소스입니다. 다음은 리소스를 탐색하고 읽는 방법입니다:

// src/resource-manager.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';

class ResourceManager {
  private client: Client;
  private resources: any[] = [];
  private resourceTemplates: any[] = [];

  constructor(client: Client) {
    this.client = client;
  }

  async discoverResources(): Promise<{ resources: any[], templates: any[] }> {
    try {
      const result = await this.client.listResources();
      this.resources = result.resources;
      this.resourceTemplates = result.resourceTemplates;
      return {
        resources: this.resources,
        templates: this.resourceTemplates
      };
    } catch (error) {
      console.error('리소스 검색 실패:', error);
      return { resources: [], templates: [] };
    }
  }

  async readResource(resourceUri: string): Promise<any[]> {
    try {
      const result = await this.client.readResource(resourceUri);
      return result.contents;
    } catch (error) {
      console.error(`리소스 읽기 오류 (${resourceUri}):`, error);
      throw error;
    }
  }

  // 템플릿 URI 생성 도우미
  createUriFromTemplate(templateName: string, params: Record<string, string>): string | null {
    const template = this.resourceTemplates.find(t => t.name === templateName);
    if (!template) {
      return null;
    }
    
    let uri = template.uriTemplate;
    Object.entries(params).forEach(([key, value]) => {
      uri = uri.replace(`{${key}}`, encodeURIComponent(value));
    });
    
    return uri;
  }
}

export { ResourceManager };

프롬프트(Prompts) 활용하기

프롬프트는 서버가 제공하는 LLM 상호작용 템플릿입니다:

// src/prompt-manager.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';

class PromptManager {
  private client: Client;
  private prompts: any[] = [];

  constructor(client: Client) {
    this.client = client;
  }

  async discoverPrompts(): Promise<any[]> {
    try {
      const result = await this.client.listPrompts();
      this.prompts = result.prompts;
      return this.prompts;
    } catch (error) {
      console.error('프롬프트 검색 실패:', error);
      return [];
    }
  }

  async getPrompt(promptName: string, args: any): Promise<any> {
    try {
      const result = await this.client.getPrompt(promptName, args);
      return result;
    } catch (error) {
      console.error(`프롬프트 가져오기 오류 (${promptName}):`, error);
      throw error;
    }
  }

  getPromptByName(promptName: string): any {
    return this.prompts.find(p => p.name === promptName);
  }

  getAllPrompts(): any[] {
    return this.prompts;
  }
}

export { PromptManager };

이제 서버 기능을 활용하는 방법을 알았으니, LLM과 통합하는 방법을 살펴보겠습니다.

LLM과 MCP 클라이언트 통합하기

MCP 클라이언트의 주요 목적은 LLM이 외부 도구와 데이터에 접근할 수 있게 하는 것입니다. 이제 LLM(이 예에서는 Anthropic Claude)과 MCP 클라이언트를 통합하는 방법을 알아보겠습니다.

// src/llm-integration.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ToolManager } from './tool-manager.js';
import { ResourceManager } from './resource-manager.js';

// LLM 클라이언트 인터페이스 (실제 구현은 프로젝트마다 다를 수 있음)
interface LLMClient {
  sendMessage(messages: any[], options?: any): Promise<any>;
}

class LLMIntegration {
  private mcpClient: Client;
  private llmClient: LLMClient;
  private toolManager: ToolManager;
  private resourceManager: ResourceManager;

  constructor(mcpClient: Client, llmClient: LLMClient) {
    this.mcpClient = mcpClient;
    this.llmClient = llmClient;
    this.toolManager = new ToolManager(mcpClient);
    this.resourceManager = new ResourceManager(mcpClient);
  }

  async initialize(): Promise<void> {
    // 서버 기능 검색
    await this.toolManager.discoverTools();
    await this.resourceManager.discoverResources();
  }

  // LLM을 위한 도구 정의 포맷
  formatToolsForLLM(): any[] {
    const tools = this.toolManager.getAllTools();
    
    // Anthropic Claude API 형식에 맞게 포맷
    return tools.map(tool => ({
      name: tool.name,
      description: tool.description || `Tool: ${tool.name}`,
      input_schema: tool.inputSchema
    }));
  }

  // 사용자 쿼리 처리
  async processUserQuery(userQuery: string): Promise<string> {
    // 대화 컨텍스트 초기화
    const messages = [
      { role: 'user', content: userQuery }
    ];
    
    // LLM에 도구 정의와 함께 쿼리 전송
    const llmResponse = await this.llmClient.sendMessage(messages, {
      tools: this.formatToolsForLLM()
    });
    
    // 도구 호출이 필요한지 확인
    if (llmResponse.tool_calls && llmResponse.tool_calls.length > 0) {
      // 각 도구 호출 처리
      for (const toolCall of llmResponse.tool_calls) {
        const { name, arguments: args, id } = toolCall;
        
        console.log(`LLM이 도구를 호출하려고 합니다: ${name}`);
        
        try {
          // 도구 호출
          const toolResult = await this.toolManager.callTool(name, args);
          
          // 대화 컨텍스트에 도구 호출 추가
          messages.push({
            role: 'assistant',
            content: null,
            tool_calls: [{ id, name, arguments: JSON.stringify(args) }]
          });
          
          // 대화 컨텍스트에 도구 결과 추가
          messages.push({
            role: 'tool',
            tool_call_id: id,
            content: toolResult[0].text
          });
        } catch (error) {
          console.error(`도구 호출 오류: ${error.message}`);
          
          // 대화 컨텍스트에 오류 정보 추가
          messages.push({
            role: 'tool',
            tool_call_id: id,
            content: `오류: ${error.message}`
          });
        }
      }
      
      // 업데이트된 컨텍스트로 LLM에 다시 요청
      const finalResponse = await this.llmClient.sendMessage(messages, {
        tools: this.formatToolsForLLM()
      });
      
      return finalResponse.content;
    }
    
    // 도구 호출이 없는 경우 원래 응답 반환
    return llmResponse.content;
  }

  // 리소스를 읽고 LLM 컨텍스트에 추가
  async addResourceToContext(resourceUri: string, messages: any[]): Promise<any[]> {
    try {
      const contents = await this.resourceManager.readResource(resourceUri);
      
      // 리소스 내용을 시스템 메시지로 추가
      messages.push({
        role: 'system',
        content: `리소스 내용 (${resourceUri}):\n${contents[0].text}`
      });
      
      return messages;
    } catch (error) {
      console.error(`리소스 추가 오류: ${error.message}`);
      
      // 오류 정보를 시스템 메시지로 추가
      messages.push({
        role: 'system',
        content: `리소스 접근 오류 (${resourceUri}): ${error.message}`
      });
      
      return messages;
    }
  }
}

export { LLMIntegration };

이 LLMIntegration 클래스는 MCP 클라이언트와 LLM 사이의 통합 계층을 제공합니다. 주요 기능은 다음과 같습니다:

  1. LLM을 위한 도구 정의 포맷팅
  2. 사용자 쿼리 처리 및 도구 호출 관리
  3. 리소스를 LLM 컨텍스트에 추가

실제 프로젝트에서는 이 클래스를 확장하여 더 다양한 기능을 추가할 수 있습니다. 예를 들어, 사용자 승인 로직, 도구 호출 로깅, 도구 호출 횟수 제한 등을 구현할 수 있습니다.

오류 처리 및 보안 고려사항

MCP 클라이언트를 개발할 때 오류 처리와 보안은 매우 중요합니다. 다음은 몇 가지 주요 고려사항입니다:

오류 처리

// src/error-handler.ts
class McpErrorHandler {
  // 오류 유형 분류
  static categorizeError(error: any): string {
    if (error.code) {
      switch (error.code) {
        case -32600: return '잘못된 요청';
        case -32601: return '메서드 없음';
        case -32602: return '잘못된 매개변수';
        case -32603: return '내부 오류';
        default: return `서버 오류 (${error.code})`;
      }
    }
    
    if (error.message) {
      if (error.message.includes('connection')) return '연결 오류';
      if (error.message.includes('timeout')) return '시간 초과';
      if (error.message.includes('permission')) return '권한 오류';
    }
    
    return '알 수 없는 오류';
  }
  
  // 사용자 친화적인 오류 메시지 생성
  static getUserFriendlyMessage(error: any): string {
    const category = this.categorizeError(error);
    
    switch (category) {
      case '연결 오류':
        return '서버에 연결할 수 없습니다. 네트워크 연결을 확인하고 서버가 실행 중인지 확인하세요.';
      case '메서드 없음':
        return '요청한 기능이 서버에서 지원되지 않습니다.';
      case '잘못된 매개변수':
        return '잘못된 매개변수가 제공되었습니다. 입력을 확인하세요.';
      case '시간 초과':
        return '서버 응답 시간이 초과되었습니다. 나중에 다시 시도하세요.';
      case '권한 오류':
        return '이 작업을 수행할 권한이 없습니다. 인증 정보를 확인하세요.';
      default:
        return `오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`;
    }
  }
  
  // 오류 로깅
  static logError(error: any, context: string = ''): void {
    const category = this.categorizeError(error);
    const timestamp = new Date().toISOString();
    
    console.error(`[${timestamp}] [${category}] ${context ? context + ': ' : ''}`, error);
  }
}

export { McpErrorHandler };

보안 고려사항

MCP 클라이언트를 개발할 때 보안은 매우 중요합니다. 다음은 몇 가지 주요 보안 고려사항입니다:

  1. 입력 검증: 사용자 입력과 LLM 출력을 항상 검증하세요.
  2. 도구 호출 승인: 중요한 작업에 대해 사용자 승인을 요청하세요.
  3. 속도 제한: 과도한 도구 호출을 방지하기 위한 속도 제한을 구현하세요.
  4. 토큰 관리: API 토큰과 인증 정보를 안전하게 관리하세요.

다음은 사용자 승인과 속도 제한을 구현하는 예입니다:

// src/security-manager.ts
class SecurityManager {
  private toolPolicies: Record<string, { requiresApproval: boolean, maxCallsPerMinute: number }> = {
    'file-write': { requiresApproval: true, maxCallsPerMinute: 5 },
    'execute-command': { requiresApproval: true, maxCallsPerMinute: 2 },
    'read-data': { requiresApproval: false, maxCallsPerMinute: 20 }
  };
  
  private toolCallCounts: Record<string, { count: number, timestamp: number }> = {};
  
  // 도구 호출 승인 확인
  async checkToolCall(toolName: string, args: any): Promise<boolean> {
    // 이 도구에 대한 정책 가져오기 (기본값은 승인 필요)
    const policy = this.toolPolicies[toolName] || { requiresApproval: true, maxCallsPerMinute: 10 };
    
    // 속도 제한 확인
    if (!this.checkRateLimit(toolName, policy.maxCallsPerMinute)) {
      console.error(`도구 ${toolName}에 대한 속도 제한 초과`);
      return false;
    }
    
    // 승인이 필요한 경우 사용자에게 물어보기
    if (policy.requiresApproval) {
      return await this.getUserApproval(toolName, args);
    }
    
    // 승인이 필요 없고 속도 제한도 초과하지 않음
    return true;
  }
  
  // 속도 제한 확인
  private checkRateLimit(toolName: string, maxCallsPerMinute: number): boolean {
    const now = Date.now();
    
    // 카운트가 없으면 초기화
    if (!this.toolCallCounts[toolName]) {
      this.toolCallCounts[toolName] = { count: 0, timestamp: now };
    }
    
    const record = this.toolCallCounts[toolName];
    
    // 1분 이상 지났으면 카운터 재설정
    if (now - record.timestamp > 60000) {
      record.count = 0;
      record.timestamp = now;
    }
    
    // 카운터 증가
    record.count++;
    
    // 제한 초과 확인
    return record.count <= maxCallsPerMinute;
  }
  
  // 사용자 승인 얻기 (실제 구현은 UI 환경에 따라 다를 수 있음)
  private async getUserApproval(toolName: string, args: any): Promise<boolean> {
    // 이 예제에서는 콘솔에서 확인을 요청
    console.log(`도구 "${toolName}" 실행 승인이 필요합니다.`);
    console.log('인수:', JSON.stringify(args, null, 2));
    
    // 실제 애플리케이션에서는 UI 대화 상자 또는 프롬프트 사용
    return new Promise<boolean>(resolve => {
      // 여기에서는 항상 승인된 것으로 가정 (실제 구현에서는 변경 필요)
      setTimeout(() => resolve(true), 100);
    });
  }
}

export { SecurityManager };

종합 예제: 완전한 MCP 클라이언트 애플리케이션

이제 모든 구성 요소를 결합한 완전한 MCP 클라이언트 애플리케이션을 만들어 보겠습니다:

// src/mcp-client-app.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ConnectionManager } from './connection-manager.js';
import { ToolManager } from './tool-manager.js';
import { ResourceManager } from './resource-manager.js';
import { PromptManager } from './prompt-manager.js';
import { LLMIntegration } from './llm-integration.js';
import { SecurityManager } from './security-manager.js';
import { McpErrorHandler } from './error-handler.js';

// LLM 클라이언트 인터페이스 (실제 구현은 프로젝트마다 다를 수 있음)
interface LLMClient {
  sendMessage(messages: any[], options?: any): Promise<any>;
}

class McpClientApp {
  private connectionManager: ConnectionManager;
  private toolManager: ToolManager;
  private resourceManager: ResourceManager;
  private promptManager: PromptManager;
  private llmIntegration: LLMIntegration;
  private securityManager: SecurityManager;
  
  constructor(clientName: string, clientVersion: string, llmClient: LLMClient) {
    this.connectionManager = new ConnectionManager(clientName, clientVersion);
    
    // 다른 관리자들은 초기화 후에 설정됨
    this.securityManager = new SecurityManager();
  }
  
  async initialize(serverPath: string, args: string[] = []): Promise<boolean> {
    try {
      // 서버에 연결
      const connected = await this.connectionManager.connect(serverPath, args);
      if (!connected) {
        return false;
      }
      
      const client = this.connectionManager.getClient();
      
      // 모든 관리자 초기화
      this.toolManager = new ToolManager(client);
      this.resourceManager = new ResourceManager(client);
      this.promptManager = new PromptManager(client);
      
      // 서버 기능 검색
      await this.toolManager.discoverTools();
      await this.resourceManager.discoverResources();
      await this.promptManager.discoverPrompts();
      
      // LLM 통합 설정
      this.llmIntegration = new LLMIntegration(client, this.securityManager);
      
      console.log('MCP 클라이언트 애플리케이션이 성공적으로 초기화되었습니다.');
      return true;
    } catch (error) {
      McpErrorHandler.logError(error, '초기화');
      console.error('초기화 오류:', McpErrorHandler.getUserFriendlyMessage(error));
      return false;
    }
  }
  
  async processUserQuery(userQuery: string): Promise<string> {
    try {
      if (!this.connectionManager.isClientConnected()) {
        throw new Error('MCP 서버에 연결되어 있지 않습니다.');
      }
      
      return await this.llmIntegration.processUserQuery(userQuery);
    } catch (error) {
      McpErrorHandler.logError(error, '쿼리 처리');
      return `오류: ${McpErrorHandler.getUserFriendlyMessage(error)}`;
    }
  }
  
  async shutdown(): Promise<void> {
    try {
      await this.connectionManager.disconnect();
      console.log('MCP 클라이언트 애플리케이션이 종료되었습니다.');
    } catch (error) {
      McpErrorHandler.logError(error, '종료');
    }
  }
  
  // 유틸리티 메서드
  getAvailableTools(): any[] {
    return this.toolManager.getAllTools();
  }
  
  getAvailableResources(): any[] {
    return this.resourceManager.getAllResources();
  }
  
  getAvailablePrompts(): any[] {
    return this.promptManager.getAllPrompts();
  }
}

export { McpClientApp };

이 McpClientApp 클래스는 모든 구성 요소를 하나의 사용하기 쉬운 API로 통합합니다. 다음과 같은 주요 기능을 제공합니다:

  1. 서버에 연결 및 초기화
  2. 서버 기능 검색 및 관리
  3. 사용자 쿼리 처리
  4. 오류 처리 및 보안 관리
  5. 애플리케이션 종료

멀티 서버 연결 관리

실제 애플리케이션에서는 여러 MCP 서버에 연결해야 할 수 있습니다. 다음은 여러 서버 연결을 관리하기 위한 확장 예제입니다:

// src/multi-server-manager.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';

interface ServerConfig {
  id: string;
  name: string;
  type: 'stdio' | 'sse';
  path?: string;
  args?: string[];
  url?: string;
  authToken?: string;
}

class MultiServerManager {
  private connections: Map<string, {
    client: Client,
    config: ServerConfig,
    tools: any[],
    resources: any[],
    prompts: any[]
  }> = new Map();
  
  async connectToServer(config: ServerConfig): Promise<boolean> {
    try {
      // 이미 연결되어 있는지 확인
      if (this.connections.has(config.id)) {
        console.log(`서버 ${config.id}에 이미 연결되어 있습니다.`);
        return true;
      }
      
      // 클라이언트 생성
      const client = new Client(
        { name: 'multi-server-client', version: '1.0.0' },
        { capabilities: { tools: {}, resources: {}, prompts: {} } }
      );
      
      // 트랜스포트 유형에 따라 연결
      if (config.type === 'stdio') {
        if (!config.path) {
          throw new Error('stdio 트랜스포트에는 경로가 필요합니다.');
        }
        
        const transport = new StdioClientTransport({
          command: config.path,
          args: config.args || []
        });
        
        await client.connect(transport);
      } else if (config.type === 'sse') {
        if (!config.url) {
          throw new Error('sse 트랜스포트에는 URL이 필요합니다.');
        }
        
        const transport = new SSEClientTransport(
          new URL(config.url)
        );
        
        if (config.authToken) {
          transport.setHeaders({
            'Authorization': `Bearer ${config.authToken}`
          });
        }
        
        await client.connect(transport);
      } else {
        throw new Error(`지원되지 않는 트랜스포트 유형: ${config.type}`);
      }
      
      // 클라이언트 초기화
      await client.initialize();
      
      // 서버 기능 검색
      const [tools, resources, prompts] = await Promise.all([
        client.listTools(),
        client.listResources(),
        client.listPrompts()
      ]);
      
      // 연결 정보 저장
      this.connections.set(config.id, {
        client,
        config,
        tools: tools.tools,
        resources: resources.resources,
        prompts: prompts.prompts
      });
      
      console.log(`서버 ${config.id}에 성공적으로 연결되었습니다.`);
      return true;
    } catch (error) {
      console.error(`서버 ${config.id}에 연결하는 중 오류 발생:`, error);
      return false;
    }
  }
  
  getClient(serverId: string): Client | null {
    return this.connections.get(serverId)?.client || null;
  }
  
  getServerInfo(serverId: string): any {
    return this.connections.get(serverId);
  }
  
  getAllServers(): string[] {
    return Array.from(this.connections.keys());
  }
  
  async disconnectFromServer(serverId: string): Promise<boolean> {
    const connection = this.connections.get(serverId);
    if (!connection) {
      return false;
    }
    
    try {
      await connection.client.close();
      this.connections.delete(serverId);
      console.log(`서버 ${serverId}와의 연결이 종료되었습니다.`);
      return true;
    } catch (error) {
      console.error(`서버 ${serverId}와의 연결 종료 중 오류 발생:`, error);
      return false;
    }
  }
  
  async disconnectAll(): Promise<void> {
    for (const serverId of this.connections.keys()) {
      await this.disconnectFromServer(serverId);
    }
  }
  
  // 모든 서버의 도구 가져오기
  getAllTools(): { serverId: string, tool: any }[] {
    const allTools: { serverId: string, tool: any }[] = [];
    
    for (const [id, connection] of this.connections.entries()) {
      for (const tool of connection.tools) {
        allTools.push({ serverId: id, tool });
      }
    }
    
    return allTools;
  }
  
  // 도구 이름으로 도구 찾기
  findTool(toolName: string): { serverId: string, tool: any } | null {
    for (const [id, connection] of this.connections.entries()) {
      const tool = connection.tools.find(t => t.name === toolName);
      if (tool) {
        return { serverId: id, tool };
      }
    }
    
    return null;
  }
  
  // 적절한 서버에서 도구 실행
  async executeTool(toolName: string, args: any): Promise<any> {
    const toolInfo = this.findTool(toolName);
    if (!toolInfo) {
      throw new Error(`도구 ${toolName}을(를) 찾을 수 없습니다.`);
    }
    
    const client = this.getClient(toolInfo.serverId);
    if (!client) {
      throw new Error(`서버 ${toolInfo.serverId}의 클라이언트를 찾을 수 없습니다.`);
    }
    
    console.log(`서버 ${toolInfo.serverId}에서 도구 ${toolName} 실행 중...`);
    return await client.callTool(toolName, args);
  }
}

export { MultiServerManager, ServerConfig };

이 MultiServerManager 클래스는 여러 MCP 서버에 대한 연결을 관리하고, 모든 서버의 도구와 리소스에 접근할 수 있는 통합 인터페이스를 제공합니다.

결론 및 다음 단계

이 가이드에서는 MCP 클라이언트 개발의 기본 개념부터 고급 기능까지 다루었습니다. 다음 주제를 다루었습니다:

  1. MCP 아키텍처 이해
  2. 기본 MCP 클라이언트 구현
  3. 연결 및 인증 관리
  4. 서버 기능 탐색 및 활용
  5. LLM 통합
  6. 오류 처리 및 보안 고려사항
  7. 멀티 서버 연결 관리

MCP는 상대적으로 새로운 프로토콜이므로 계속 발전하고 있습니다. 앞으로 더 많은 기능과 개선 사항이 추가될 것으로 예상됩니다.

MCP 클라이언트 개발을 더 깊이 탐구하고 싶다면 다음 단계를 고려해 보세요:

  1. 샘플링 기능 구현: MCP 서버가 LLM 샘플링을 요청할 수 있도록 하는 기능 추가
  2. 리소스 구독: 리소스 변경 사항에 대한 실시간 알림 처리
  3. 프로그레스 보고: 장기 실행 작업의 진행 상황 보고 처리
  4. 고급 보안 패턴: 더 강력한 보안 및 인증 메커니즘 구현

MCP는 LLM과 외부 도구 및 데이터 소스 간의 표준화된 통신을 가능하게 함으로써 AI 통합의 미래를 형성하는 데 중요한 역할을 할 것입니다. 이 가이드가 여러분의 MCP 클라이언트 개발 여정에 도움이 되길 바랍니다!