MCP로 나만의 뉴스레터 구축하기

04.03.2025.장병익 매니저
#MCP
#newsletter
#Architecture
Report

개요

2024년 11월 Anthropic에서 MCP(Model Context Protocol)가 공개되었습니다. MCP는 사용자가 필요한 기능을 정의한 MCP Server와 MCP Client(예: Claude Desktop) 간의 통신을 위한 중간 Protocol 역할을 하는 통신체계입니다.

이 프로토콜은 LLM과 외부 도구 및 서비스 간의 상호작용을 표준화하고, 개발자가 자신만의 AI 애플리케이션을 보다 쉽게 구축할 수 있도록 돕는 역할을 합니다. MCP를 통해 Claude와 같은 LLM이 API 호출, 데이터 분석, 파일 처리 등의 작업을 자연어 명령만으로 수행할 수 있게 되었습니다.

구성

  • MCP Server는 사용자가 만든 Server 입니다. 다른 외부 API 데이터를 가져온다던지, 계산식을 만든다던지 등 LLM에서 사용 할 수 있는 함수를 만들어 둔 서버 입니다.

  • MCP(Message Control Protocol) Client는 LLM(Large Language Model)과의 상호작용을 관리하는 인터페이스입니다.

기능

Tool

  • LLM에서 사용 할 수 있는 함수
@mcp.tool()
def process1(a: int, b: int) -> int:
    """Add two numbers"""
    return a + b

Prompt

  • 미리 작성된 LLM에서 사용 할 수 있는 Prompt
@mcp.prompt()
def review_code(code: str) -> str:
    return f"End add O :\n\n{code}"@mcp.prompt()
def debug_error(error: str) -> list[base.Message]:
    return [
        base.UserMessage("I'm seeing this error:"),
        base.UserMessage(error),
        base.AssistantMessage("I'll help debug that. What have you tried so far?"),
    ]

위와 같이 두개의 Prompt를 MCP Server에 작성한 뒤

prompt 창 하단에 위와 같은 버튼을 누르면

위와 같이 작성된 프롬프트 리스트에서 원하는 프롬프트를 적용 할 수 있게 됩니다.

Resources

  • DB와 API 등 외부데이터들을 여기서는 Resources라고 부릅니다.

뉴스레터 만들기

작업 순서

  1. Claude Desktop에서 server.py에 newsletter(tool) 함수 요청을 합니다.
  2. newscrawler.py 에서 크롤링을 진행하고 [filesystem]에 combined_news_{%Y%M%D}.csv 파일로 저장합니다.
  3. Claude Desktop에서 filesystem을 통해 combined_news_{%Y%M%D}.csv 읽어서 LLM이 뉴스레터를 요약해서 만들어 줍니다.

MCP Server 작성

newscrawler.py에서 BASE 부분에 현재 작업하는 폴더로 변경해주세요

# newscrawler.py
from bs4 import BeautifulSoup
import requests
import pandas as pd
import datetime
import time
import random
import os

BASE = "/Users/bijang/techblog" # 뉴스 레터 폴더로 맞춰 주세요

class NewsCrawler:
    """
    다양한 뉴스 사이트에서 뉴스 기사를 크롤링하는 클래스
    """
    
    def __init__(self, output_dir="crawled_news"):
        """
        크롤러 초기화
        
        Args:
            output_dir (str): 크롤링한 결과를 저장할 디렉토리 경로
        """
        self.headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
        }
        self.output_dir = output_dir
        
        # 결과 저장 디렉토리 생성
        if not os.path.exists(output_dir):
            os.makedirs(output_dir)
    
    def crawl_naver_news(self, keyword, pages=2):
        """
        네이버 뉴스에서 키워드 검색 결과를 크롤링
        
        Args:
            keyword (str): 검색할 키워드
            pages (int): 크롤링할 페이지 수
            
        Returns:
            pd.DataFrame: 크롤링한 뉴스 데이터
        """
        news_list = []
        
        for page in range(1, pages + 1):
            url = f"https://search.naver.com/search.naver?where=news&query={keyword}&start={(page-1)*10+1}"
            
            response = requests.get(url, headers=self.headers)
            if response.status_code != 200:
                print(f"Error: 페이지 접근 실패 (상태 코드: {response.status_code})")
                continue
                
            soup = BeautifulSoup(response.text, 'html.parser')
            news_items = soup.select('.list_news .bx')
            
            for item in news_items:
                try:
                    title_elem = item.select_one('.news_tit')
                    title = title_elem.text
                    link = title_elem['href']
                    
                    source_elem = item.select_one('.info.press')
                    source = source_elem.text if source_elem else "Unknown"
                    
                    date_elem = item.select_one('.info.time')
                    date = date_elem.text if date_elem else "Unknown"
                    
                    desc_elem = item.select_one('.dsc_txt')
                    description = desc_elem.text.strip() if desc_elem else ""
                    
                    news_list.append({
                        'title': title,
                        'source': source,
                        'date': date,
                        'description': description,
                        'link': link,
                        'keyword': keyword
                    })
                    
                except Exception as e:
                    print(f"Error: 뉴스 항목 파싱 실패 - {str(e)}")
            
            # 너무 빠른 요청 방지를 위한 대기
            time.sleep(random.uniform(1.0, 3.0))
            
        # 결과를 DataFrame으로 변환
        df = pd.DataFrame(news_list)
        
        # 결과 저장
        timestamp = datetime.datetime.now().strftime("%Y%m%d")
        filename = f"{self.output_dir}/naver_news_{keyword}_{timestamp}.csv"
        df.to_csv(filename, index=False, encoding='utf-8-sig')
        return df
    
    def crawl_daum_news(self, keyword, pages=2):
        """
        다음 뉴스에서 키워드 검색 결과를 크롤링
        
        Args:
            keyword (str): 검색할 키워드
            pages (int): 크롤링할 페이지 수
            
        Returns:
            pd.DataFrame: 크롤링한 뉴스 데이터
        """
        news_list = []
        
        for page in range(1, pages + 1):
            url = f"https://search.daum.net/search?w=news&q={keyword}&p={page}"
            
            response = requests.get(url, headers=self.headers)
            if response.status_code != 200:
                print(f"Error: 페이지 접근 실패 (상태 코드: {response.status_code})")
                continue
                
            soup = BeautifulSoup(response.text, 'html.parser')
            news_items = soup.select('.c-item.c-item-general')
            
            for item in news_items:
                try:
                    title_elem = item.select_one('.tit-g')
                    title = title_elem.text.strip()
                    link = title_elem.select_one('a')['href']
                    
                    source_elem = item.select_one('.cont-info .f-l:nth-child(1)')
                    source = source_elem.text.strip() if source_elem else "Unknown"
                    
                    date_elem = item.select_one('.cont-info .f-l:nth-child(2)')
                    date = date_elem.text.strip() if date_elem else "Unknown"
                    
                    desc_elem = item.select_one('.desc')
                    description = desc_elem.text.strip() if desc_elem else ""
                    
                    news_list.append({
                        'title': title,
                        'source': source,
                        'date': date,
                        'description': description,
                        'link': link,
                        'keyword': keyword
                    })
                    
                except Exception as e:
                    print(f"Error: 뉴스 항목 파싱 실패 - {str(e)}")
            
            # 너무 빠른 요청 방지를 위한 대기
            time.sleep(random.uniform(1.0, 3.0))
            
        # 결과를 DataFrame으로 변환
        df = pd.DataFrame(news_list)
        
        # 결과 저장
        timestamp = datetime.datetime.now().strftime("%Y%m%d")
        filename = f"{self.output_dir}/daum_news_{keyword}_{timestamp}.csv"
        df.to_csv(filename, index=False, encoding='utf-8-sig')
        return df
    
    def crawl_full_article(self, url):
        """
        뉴스 기사의 전체 내용을 크롤링
        
        Args:
            url (str): 뉴스 기사 URL
            
        Returns:
            dict: 크롤링한 기사 내용
        """
        try:
            response = requests.get(url, headers=self.headers)
            if response.status_code != 200:
                return {"error": f"페이지 접근 실패 (상태 코드: {response.status_code})"}
            
            soup = BeautifulSoup(response.text, 'html.parser')
            
            # 네이버 뉴스 형식 확인
            if "news.naver.com" in url:
                title = soup.select_one('#title_area')
                title = title.text.strip() if title else "제목 없음"
                
                content_elem = soup.select_one('#dic_area')
                content = content_elem.text.strip() if content_elem else "내용 없음"
                
                date_elem = soup.select_one('.media_end_head_info_datestamp_time')
                date = date_elem.text.strip() if date_elem else "날짜 정보 없음"
                
            # 다음 뉴스 형식 확인    
            elif "news.daum.net" in url:
                title = soup.select_one('.tit_view')
                title = title.text.strip() if title else "제목 없음"
                
                content_elem = soup.select_one('#harmonyContainer')
                content = content_elem.text.strip() if content_elem else "내용 없음"
                
                date_elem = soup.select_one('.num_date')
                date = date_elem.text.strip() if date_elem else "날짜 정보 없음"
                
            # 기타 뉴스 사이트 - 일반적인 접근 시도
            else:
                title = soup.select_one('h1') or soup.select_one('h2') or soup.select_one('title')
                title = title.text.strip() if title else "제목 없음"
                
                # 여러 일반적인 콘텐츠 영역 태그를 시도
                content_selectors = ['article', '.article', '.content', '#content', '.article-content', '.news-content']
                content_elem = None
                
                for selector in content_selectors:
                    content_elem = soup.select_one(selector)
                    if content_elem:
                        break
                        
                content = content_elem.text.strip() if content_elem else "내용을 찾을 수 없습니다"
                date = "날짜 정보 없음"
            
            return {
                "title": title,
                "content": content,
                "date": date,
                "url": url
            }
            
        except Exception as e:
            return {"error": str(e), "url": url}
    
    def batch_crawl(self, keywords, news_sites=None):
        """
        여러 키워드와 뉴스 사이트에 대해 일괄 크롤링 수행
        
        Args:
            keywords (list): 검색할 키워드 목록
            news_sites (list, optional): 크롤링할 뉴스 사이트 목록 ('naver', 'daum')
            
        Returns:
            dict: 키워드별 크롤링 결과
        """
        if news_sites is None:
            news_sites = ['naver', 'daum']
        filename = None 
        results = {}
        
        for keyword in keywords:
            keyword_results = {}
            
            if 'naver' in news_sites:
                naver_df = self.crawl_naver_news(keyword)
                keyword_results['naver'] = naver_df
                
            if 'daum' in news_sites:
                daum_df = self.crawl_daum_news(keyword)
                keyword_results['daum'] = daum_df
                
            results[keyword] = keyword_results
            
        # 크롤링 결과를 합쳐서 하나의 CSV로 저장
        combined_news = []
        
        for keyword, site_results in results.items():
            for site, df in site_results.items():
                if not df.empty:
                    df['site'] = site
                    combined_news.append(df)
        
        if combined_news:
            combined_df = pd.concat(combined_news, ignore_index=True)
            timestamp = datetime.datetime.now().strftime("%Y%m%d")
            filename = f"{self.output_dir}/combined_news_{timestamp}.csv"
            combined_df.to_csv(filename, index=False, encoding='utf-8-sig')
            print(f"통합 결과: {len(combined_df)}개 기사를 {filename}에 저장했습니다.")
            return filename
        return filename

def crawling(keywords):
    crawler = NewsCrawler(output_dir= BASE +"/news_results")
    filename = crawler.batch_crawl(keywords, news_sites=['naver', 'daum'])
    return filename

# server.py
from mcp.server.fastmcp import FastMCP, Image
from newscrawler import crawling

mcp = FastMCP("Demo")

@mcp.tool()
def newsletter(keyword: str) -> str:
    """news output news crawlling data csv file"""
    keyword_list = keyword.split(",")
    results = crawling(keyword_list)
    return results

Claude Desktop Setting

위에 작성한 파일을 아래와 같은 디렉토리 구조로 저장해주세요

techblog/
│
├── news_results/
│   ├── combined_news_20250402.csv --> crawling.py
│
├── newscrawler.py
├── server.py

techoblog에 Claude Desktop이 접근할 수 있는 접근 권한을 부여 해야 합니다.

claude 설정 -> 개발자로 접속하시면 아래와 같은 화면이 나옵니다.
설정 편집을 누르게 되면 “claude_desktop_config.json” 파일 경로로 이동합니다.

claude_desktop_config.json 파일 수정

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/Users/bijang/Desktop", <-- 내가 허용하고 하는 파일 경로
        "/Users/bijang/Downloads",  <-- 내가 허용하고 하는 파일 경로
        "/Users/bijang/techblog"  <-- 내가 허용하고 하는 파일 경로
      ]
    },
    "Demo": {
      "command": "uv",
      "args": [
        "run",
        "--with",
        "mcp[cli],Pillow,bs4,requests,pandas", <-- 필요한 Python 패키지명
        "mcp",
        "run",
        "/Users/bijang/techblog/server.py"  <-- 위에 작성된 server.py 경로
      ]
    }
  }
}

위와 같이 MCP Client는 두가지의 MCP Server와 연동합니다. filesystem, Demo
Demo에서는 Crawling을 통해 Crawling 한 데이터를 .csv 파일로 만들 예정이고
filesystem을 통해 Crawling된 .csv 파일을 읽어서 LLM에 데이터를 넘겨 줄 예정입니다.

결과물

“미슐랭 주제로 뉴스레터 만들어줘” 라는 말을 통해서

“Demo의 newsletter…”
“filesystem의 read_file…”

MCP를 활용하고 있다는 로그가 출력되며
오른쪽에 크롤링 한 데이터를 통해 뉴스레터를 잘 작성하고 있는게 보입니다.

! 주의 할점
현재는 Sonnect 3.7 같은 경우는 “미슐랭 주제로 뉴스레터 만들어줘”라는 명령어로 두 가지 작업을 하나의 작업으로 인식하고 정상적으로 출력하지만 Claude 3.5 Haiku 같은 경우는 “Demo newsletter” 까지는 이해하지만 조금 더 filesystem에서 읽어오지는 못합니다.

그래서 Haiku 같은 경우 조금 더 prompt를 자세하게 적어줄 필요가 있습니다.

“미슐랭 주제로 뉴스레터 만들어줘 뉴스레터 기능은 뉴스레터 파일이 나오기때문에 그 파일을 읽어서 만들게 해줘”

마무리 정리

MCP는 단순한 개인 사용자 인터페이스를 넘어서, 개발자를 위한 강력한 프로토콜로서의 잠재력을 보여줍니다. 이 접근 방식은 코드와 자연어 사이의 경계를 허물고, 보다 직관적인 프로그래밍 패러다임을 제시합니다.

현재 시스템에 안정화 작업이 더해진다면, 다음과 같은 활용 가능성이 있습니다:

  • 사내 지능형 플랫폼: 비개발자도 쉽게 데이터 처리 및 분석 작업을 수행할 수 있는 환경
  • 개인화된 AI 비서: 사용자의 의도를 정확히 파악하고 적절한 기능을 실행하는 지능형 시스템
  • 개발 생산성 도구: 자연어로 코드 기능을 호출하고 통합하는 새로운 개발 패러다임

이러한 MCP 기반 시스템은 기술적 진입 장벽을 낮추고, 더 많은 사용자가 컴퓨팅 기술의 혜택을 누릴 수 있게 해줄 것입니다.

출처 : https://www.descope.com/learn/post/mcp