본문 바로가기
개발

Claude를 활용한 MCP(Model Context Protocol) 설정 가이드 - Part 2: MCP 서버 구현하기 🛠️

by D-Project 2025. 3. 26.

안녕하세요! 지난 포스팅에서 MCP의 기본 개념을 살펴봤는데요, 오늘은 본격적으로 MCP 서버를 구현하는 방법에 대해 자세히 알아보겠습니다. 직접 따라하며 Claude와 함께하는 강력한 AI 시스템을 구축해보세요! 😊

MCP 서버 아키텍처 이해하기 🏗️

MCP 서버는 Claude와 외부 시스템 사이의 중개자 역할을 합니다. 서버는 크게 다음과 같은 구성요소로 이루어져 있습니다:

  1. 라우터(Router): 클라이언트의 요청을 적절한 핸들러로 전달합니다
  2. 핸들러(Handler): 실제 작업을 수행하는 함수들입니다
  3. 인증(Authentication): 요청이 유효한지 확인합니다
  4. 로깅(Logging): 서버 활동을 기록합니다
  5. 에러 처리(Error Handling): 오류 상황을 관리합니다

Node.js로 MCP 서버 구현하기 🚀

이제 Node.js를 사용하여 간단한 MCP 서버를 구현해보겠습니다. 이 서버는 파일 시스템과 상호작용하고 웹 검색을 수행할 수 있는 기능을 포함합니다.

1. 프로젝트 설정

먼저 필요한 패키지를 설치하고 기본 프로젝트 구조를 만들어 보겠습니다.

// 프로젝트 디렉토리 생성 및 초기화
// 터미널에서 다음 명령어 실행:

mkdir claude-mcp-server
cd claude-mcp-server
npm init -y

// 필요한 패키지 설치
npm install express cors dotenv axios fs-extra

// 프로젝트 구조
/*
claude-mcp-server/
├── .env                  # 환경 변수 파일
├── package.json          # 패키지 정보
├── index.js              # 서버 진입점
├── handlers/             # 핸들러 함수들
│   ├── fileHandler.js    # 파일 시스템 핸들러
│   └── searchHandler.js  # 웹 검색 핸들러
└── utils/                # 유틸리티 함수들
    ├── auth.js           # 인증 관련 유틸리티
    └── logger.js         # 로깅 유틸리티
*/

2. 환경 변수 설정 (.env 파일)

보안 키와 설정을 관리하기 위한 환경 변수 파일을 생성합니다.

# MCP 서버 설정
PORT=3000
MCP_API_KEY=your_secure_api_key_here

# 파일 시스템 설정
BASE_DIR=/path/to/allowed/directory

# 검색 API 설정 (예: 브레이브 검색)
SEARCH_API_KEY=your_search_api_key_here
SEARCH_API_URL=https://api.search.brave.com/res/v1/web/search

3. 인증 및 로깅 유틸리티 구현

보안과 로깅을 위한 유틸리티 함수를 만들어 보겠습니다.

// utils/auth.js
// 인증 미들웨어
require('dotenv').config();

const authenticateRequest = (req, res, next) => {
  const apiKey = req.headers['x-api-key'];

  if (!apiKey || apiKey !== process.env.MCP_API_KEY) {
    console.log('인증 실패: 유효하지 않은 API 키');
    return res.status(401).json({ 
      error: '인증 실패: 유효하지 않은 API 키입니다.' 
    });
  }

  // 인증 성공
  next();
};

module.exports = { authenticateRequest };

// utils/logger.js
// 간단한 로깅 시스템
const fs = require('fs-extra');
const path = require('path');

// 로그 디렉토리 확인 및 생성
const logDir = path.join(__dirname, '../logs');
fs.ensureDirSync(logDir);

const logFilePath = path.join(logDir, `mcp-${new Date().toISOString().split('T')[0]}.log`);

const logLevels = {
  ERROR: 'ERROR',
  WARN: 'WARN',
  INFO: 'INFO',
  DEBUG: 'DEBUG'
};

const logger = {
  log: (level, message, data = {}) => {
    const timestamp = new Date().toISOString();
    const logEntry = {
      timestamp,
      level,
      message,
      data
    };

    // 콘솔에 로그 출력
    console.log(`[${timestamp}] [${level}] ${message}`, data);

    // 파일에 로그 저장
    fs.appendFileSync(
      logFilePath, 
      `${JSON.stringify(logEntry)}\n`, 
      { encoding: 'utf8' }
    );
  },

  error: (message, data) => logger.log(logLevels.ERROR, message, data),
  warn: (message, data) => logger.log(logLevels.WARN, message, data),
  info: (message, data) => logger.log(logLevels.INFO, message, data),
  debug: (message, data) => logger.log(logLevels.DEBUG, message, data),
};

module.exports = logger;

4. 파일 시스템 핸들러 구현

Claude가 로컬 파일 시스템과 상호작용할 수 있도록 핸들러를 구현합니다.

// handlers/fileHandler.js
const fs = require('fs-extra');
const path = require('path');
const logger = require('../utils/logger');
require('dotenv').config();

// 허용된 기본 디렉토리
const BASE_DIR = process.env.BASE_DIR || path.join(__dirname, '../data');

// 경로가 허용된 디렉토리 내에 있는지 확인
const isPathSafe = (requestedPath) => {
  // 절대 경로로 변환
  const absolutePath = path.resolve(requestedPath);
  const baseDirPath = path.resolve(BASE_DIR);

  // 경로가 기본 디렉토리 내에 있는지 확인
  return absolutePath.startsWith(baseDirPath);
};

// 파일 정보 가져오기
const getFileInfo = async (req, res) => {
  try {
    const { filePath } = req.body;

    if (!filePath) {
      return res.status(400).json({ error: '파일 경로가 필요합니다.' });
    }

    const fullPath = path.join(BASE_DIR, filePath);

    // 경로 안전성 검사
    if (!isPathSafe(fullPath)) {
      logger.warn('안전하지 않은 경로 접근 시도', { path: fullPath });
      return res.status(403).json({ error: '허용되지 않은 디렉토리 접근입니다.' });
    }

    // 파일 존재 확인
    const exists = await fs.pathExists(fullPath);
    if (!exists) {
      return res.status(404).json({ error: '파일이 존재하지 않습니다.' });
    }

    // 파일 정보 가져오기
    const stats = await fs.stat(fullPath);
    const isDirectory = stats.isDirectory();

    const fileInfo = {
      name: path.basename(fullPath),
      path: filePath,
      size: stats.size,
      isDirectory,
      created: stats.birthtime,
      modified: stats.mtime,
      accessed: stats.atime
    };

    logger.info('파일 정보 요청 성공', { path: filePath });
    return res.json(fileInfo);

  } catch (error) {
    logger.error('파일 정보 가져오기 실패', { error: error.message });
    return res.status(500).json({ error: `파일 정보를 가져올 수 없습니다: ${error.message}` });
  }
};

// 파일 읽기
const readFile = async (req, res) => {
  try {
    const { filePath } = req.body;

    if (!filePath) {
      return res.status(400).json({ error: '파일 경로가 필요합니다.' });
    }

    const fullPath = path.join(BASE_DIR, filePath);

    // 경로 안전성 검사
    if (!isPathSafe(fullPath)) {
      logger.warn('안전하지 않은 경로 접근 시도', { path: fullPath });
      return res.status(403).json({ error: '허용되지 않은 디렉토리 접근입니다.' });
    }

    // 파일 존재 확인
    const exists = await fs.pathExists(fullPath);
    if (!exists) {
      return res.status(404).json({ error: '파일이 존재하지 않습니다.' });
    }

    // 파일 상태 확인
    const stats = await fs.stat(fullPath);
    if (stats.isDirectory()) {
      return res.status(400).json({ error: '디렉토리가 아닌 파일 경로가 필요합니다.' });
    }

    // 파일 읽기
    const content = await fs.readFile(fullPath, 'utf8');

    logger.info('파일 읽기 성공', { path: filePath });
    return res.json({ content });

  } catch (error) {
    logger.error('파일 읽기 실패', { error: error.message });
    return res.status(500).json({ error: `파일을 읽을 수 없습니다: ${error.message}` });
  }
};

// 파일 쓰기
const writeFile = async (req, res) => {
  try {
    const { filePath, content } = req.body;

    if (!filePath || content === undefined) {
      return res.status(400).json({ error: '파일 경로와 내용이 필요합니다.' });
    }

    const fullPath = path.join(BASE_DIR, filePath);

    // 경로 안전성 검사
    if (!isPathSafe(fullPath)) {
      logger.warn('안전하지 않은 경로 접근 시도', { path: fullPath });
      return res.status(403).json({ error: '허용되지 않은 디렉토리 접근입니다.' });
    }

    // 디렉토리 생성 (필요한 경우)
    await fs.ensureDir(path.dirname(fullPath));

    // 파일 쓰기
    await fs.writeFile(fullPath, content, 'utf8');

    logger.info('파일 쓰기 성공', { path: filePath });
    return res.json({ success: true, message: '파일이 성공적으로 작성되었습니다.' });

  } catch (error) {
    logger.error('파일 쓰기 실패', { error: error.message });
    return res.status(500).json({ error: `파일을 쓸 수 없습니다: ${error.message}` });
  }
};

// 디렉토리 목록 가져오기
const listDirectory = async (req, res) => {
  try {
    const { dirPath } = req.body;

    // 기본 경로 사용 (dirPath가 없는 경우)
    const relativePath = dirPath || '';
    const fullPath = path.join(BASE_DIR, relativePath);

    // 경로 안전성 검사
    if (!isPathSafe(fullPath)) {
      logger.warn('안전하지 않은 경로 접근 시도', { path: fullPath });
      return res.status(403).json({ error: '허용되지 않은 디렉토리 접근입니다.' });
    }

    // 디렉토리 존재 확인
    const exists = await fs.pathExists(fullPath);
    if (!exists) {
      return res.status(404).json({ error: '디렉토리가 존재하지 않습니다.' });
    }

    // 디렉토리 확인
    const stats = await fs.stat(fullPath);
    if (!stats.isDirectory()) {
      return res.status(400).json({ error: '해당 경로는 디렉토리가 아닙니다.' });
    }

    // 디렉토리 내용 읽기
    const items = await fs.readdir(fullPath);

    // 각 항목의 정보 가져오기
    const itemDetails = await Promise.all(
      items.map(async (item) => {
        const itemPath = path.join(fullPath, item);
        const itemStats = await fs.stat(itemPath);

        return {
          name: item,
          path: path.join(relativePath, item),
          isDirectory: itemStats.isDirectory(),
          size: itemStats.size,
          modified: itemStats.mtime
        };
      })
    );

    logger.info('디렉토리 목록 요청 성공', { path: dirPath });
    return res.json({ items: itemDetails });

  } catch (error) {
    logger.error('디렉토리 목록 가져오기 실패', { error: error.message });
    return res.status(500).json({ error: `디렉토리 목록을 가져올 수 없습니다: ${error.message}` });
  }
};

module.exports = {
  getFileInfo,
  readFile,
  writeFile,
  listDirectory
};

5. 웹 검색 핸들러 구현

Claude가 인터넷 검색을 수행할 수 있도록 검색 기능을 구현합니다.

// handlers/searchHandler.js
const axios = require('axios');
const logger = require('../utils/logger');
require('dotenv').config();

// 웹 검색 수행
const performWebSearch = async (req, res) => {
  try {
    const { query, count = 10 } = req.body;

    if (!query) {
      return res.status(400).json({ error: '검색어가 필요합니다.' });
    }

    // 검색어 길이 제한 (예: 최대 100자)
    if (query.length > 100) {
      return res.status(400).json({ error: '검색어는 100자 이내여야 합니다.' });
    }

    // 결과 개수 유효성 검사
    const resultCount = Math.min(Math.max(1, count), 50); // 1~50개 사이 제한

    // 브레이브 검색 API 호출 (예시)
    const response = await axios({
      method: 'GET',
      url: process.env.SEARCH_API_URL,
      headers: {
        'Accept': 'application/json',
        'X-API-Key': process.env.SEARCH_API_KEY
      },
      params: {
        q: query,
        count: resultCount
      }
    });

    // 검색 결과 가공 (실제 API 응답 구조에 맞게 조정 필요)
    const searchResults = response.data.results || [];

    // 필요한 정보만 추출하여 반환
    const formattedResults = searchResults.map(result => ({
      title: result.title,
      description: result.description,
      url: result.url,
      publishedDate: result.publishedDate
    }));

    logger.info('웹 검색 성공', { query, resultCount: formattedResults.length });
    return res.json({ results: formattedResults });

  } catch (error) {
    logger.error('웹 검색 실패', { error: error.message });

    // API 오류 응답 처리
    if (error.response) {
      return res.status(error.response.status).json({ 
        error: `검색 API 오류: ${error.response.data.message || error.message}` 
      });
    }

    return res.status(500).json({ error: `검색을 수행할 수 없습니다: ${error.message}` });
  }
};

module.exports = {
  performWebSearch
};

6. 메인 서버 파일 구현

이제 모든 핸들러를 통합하여 MCP 서버의 메인 파일을 만들어 보겠습니다.

// index.js - MCP 서버 메인 파일
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs-extra');
const { authenticateRequest } = require('./utils/auth');
const logger = require('./utils/logger');
const fileHandler = require('./handlers/fileHandler');
const searchHandler = require('./handlers/searchHandler');

require('dotenv').config();

// Express 앱 생성
const app = express();
const PORT = process.env.PORT || 3000;

// 기본 디렉토리 확인/생성
const BASE_DIR = process.env.BASE_DIR || path.join(__dirname, 'data');
fs.ensureDirSync(BASE_DIR);

// 미들웨어 설정
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 서버 상태 확인 엔드포인트 (인증 필요 없음)
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// 서버 정보 엔드포인트 (MCP 사양 정보)
app.get('/.well-known/mcp-server-info', (req, res) => {
  res.json({
    name: "Claude MCP Server",
    version: "1.0.0",
    description: "MCP 서버 - 파일 시스템 및 웹 검색 기능",
    capabilities: [
      "file_operations",
      "web_search"
    ],
    endpoints: {
      "file/info": "파일 정보 조회",
      "file/read": "파일 읽기",
      "file/write": "파일 쓰기",
      "file/list": "디렉토리 목록 조회",
      "search/web": "웹 검색 수행"
    }
  });
});

// 파일 시스템 엔드포인트 (인증 필요)
app.post('/file/info', authenticateRequest, fileHandler.getFileInfo);
app.post('/file/read', authenticateRequest, fileHandler.readFile);
app.post('/file/write', authenticateRequest, fileHandler.writeFile);
app.post('/file/list', authenticateRequest, fileHandler.listDirectory);

// 검색 엔드포인트 (인증 필요)
app.post('/search/web', authenticateRequest, searchHandler.performWebSearch);

// 오류 처리 미들웨어
app.use((err, req, res, next) => {
  logger.error('서버 오류', { error: err.message, stack: err.stack });
  res.status(500).json({ error: '서버 내부 오류가 발생했습니다.' });
});

// 404 Not Found 처리
app.use((req, res) => {
  logger.warn('존재하지 않는 엔드포인트 접근', { path: req.originalUrl });
  res.status(404).json({ error: '요청한 리소스를 찾을 수 없습니다.' });
});

// 서버 시작
app.listen(PORT, () => {
  logger.info(`MCP 서버가 포트 ${PORT}에서 실행 중입니다.`);
  logger.info(`기본 디렉토리: ${BASE_DIR}`);
  console.log(`MCP 서버가 포트 ${PORT}에서 실행 중입니다.`);
});

Claude와 MCP 서버 연결하기 🔄

이제 구현한 MCP 서버를 Claude Desktop과 연결하는 방법을 알아보겠습니다.

  1. 서버 실행: 터미널에서 node index.js 명령어로 서버를 실행합니다.
  2. Claude Desktop 설정:

Claude Desktop에서 MCP 서버를 연결하려면 다음 단계를 따릅니다:

  • Claude Desktop 앱을 실행합니다
  • 설정 메뉴로 이동합니다 (일반적으로 왼쪽 하단 설정 아이콘)
  • "MCP 서버" 또는 "개발자 설정" 섹션을 찾습니다
  • "새 서버 추가" 버튼을 클릭합니다
  • 서버 URL(예: http://localhost:3000)과 API 키를 입력합니다
  • "연결 테스트" 버튼을 클릭하여 연결을 확인합니다
  • 연결이 성공하면 "저장" 버튼을 클릭합니다

MCP 서버 테스트 및 디버깅 🔍

MCP 서버를 효과적으로 테스트하려면 다음 방법을 사용할 수 있습니다:

1. Postman이나 curl을 사용한 API 테스트

서버가 올바르게 작동하는지 먼저 API 클라이언트를 통해 확인합니다:

# 서버 상태 확인 (인증 필요 없음)
curl http://localhost:3000/health

# 서버 정보 확인 (인증 필요 없음)
curl http://localhost:3000/.well-known/mcp-server-info

# 디렉토리 목록 조회 (인증 필요)
curl -X POST \
  http://localhost:3000/file/list \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your_secure_api_key_here" \
  -d '{"dirPath": ""}'

# 파일 읽기 (인증 필요)
curl -X POST \
  http://localhost:3000/file/read \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your_secure_api_key_here" \
  -d '{"filePath": "example.txt"}'

# 파일 쓰기 (인증 필요)
curl -X POST \
  http://localhost:3000/file/write \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your_secure_api_key_here" \
  -d '{"filePath": "new-example.txt", "content": "Hello, MCP World!"}'

# 웹 검색 (인증 필요)
curl -X POST \
  http://localhost:3000/search/web \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your_secure_api_key_here" \
  -d '{"query": "MCP 서버 Claude", "count": 5}'

2. 로그 확인 및 디버깅

서버 실행 중 발생하는 문제를 해결하기 위한 디버깅 팁:

  • 로그 확인: logs 디렉토리에서 생성된 로그 파일을 확인합니다.
  • 콘솔 출력: 터미널 창에서 실시간 로그 메시지를 모니터링합니다.
  • API 응답 검증: 반환된 응답이 예상과 일치하는지 확인합니다.
  • 권한 문제: 파일 시스템 작업 시 파일 및 디렉토리 권한을 확인합니다.

3. Claude를 통한 통합 테스트

Claude와 MCP 서버가 연결된 후, 다음과 같은 명령을 사용하여 통합 테스트를 수행할 수 있습니다:

  • 파일 읽기: "내 컴퓨터에서 example.txt 파일을 읽어주세요."
  • 디렉토리 탐색: "내 문서 폴더에 어떤 파일이 있는지 알려주세요."
  • 웹 검색: "MCP 서버에 대한 최신 정보를 검색해주세요."
  • 파일 작성: "다음 내용을 test.txt 파일로 저장해주세요: [내용]"

다음 포스팅 예고 🔮

다음 포스팅에서는 MCP 서버를 확장하는 방법에 대해 알아보겠습니다. 데이터베이스 연결, API 통합, 고급 보안 설정 등 MCP 서버를 더욱 강력하게 만드는 방법을 소개해 드리겠습니다.

마무리 🌟

이번 포스팅에서는 Node.js를 사용하여 기본적인 MCP 서버를 구현하고 Claude Desktop과 연결하는 방법을 살펴보았습니다. 파일 시스템 핸들러와 웹 검색 기능을 통해 Claude의 능력을 크게 확장시킬 수 있습니다.

MCP 서버 구현은:

  1. 프로젝트 설정 및 의존성 설치
  2. 핵심 유틸리티 작성 (인증, 로깅)
  3. 핸들러 구현 (파일 시스템, 웹 검색)
  4. 메인 서버 파일 작성
  5. Claude와 연결 및 테스트

이러한 단계로 진행됩니다.

다음 포스팅에서 더 많은 고급 기능에 대해 알아보겠습니다. 질문이나 의견이 있으시면 댓글로 남겨주세요! 😊