MCP로 나만의 뉴스레터 구축하기
개요
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라고 부릅니다.
뉴스레터 만들기
작업 순서
- Claude Desktop에서 server.py에 newsletter(tool) 함수 요청을 합니다.
- newscrawler.py 에서 크롤링을 진행하고 [filesystem]에 combined_news_{%Y%M%D}.csv 파일로 저장합니다.
- 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 기반 시스템은 기술적 진입 장벽을 낮추고, 더 많은 사용자가 컴퓨팅 기술의 혜택을 누릴 수 있게 해줄 것입니다.