초보 서버를 실전 서버로: Resources, Prompts, Notifications, 고급 기능
초보 서버를 실전 서버로: Resources, Prompts, Notifications, 고급 기능
지난 장에서 우리는 도구를 가진 아주 작은 MCP 서버를 직접 만들어 봤다. 그 서버는 분명 훌륭했다. 말을 걸면 대답했고, 함수를 호출했고, 결과도 돌려줬다. 그런데 아직은 조금 순진하다. 마치 망치 하나는 들고 있는데 작업 설명서도 없고, 재료 창고도 없고, 일이 끝났는지 알려주는 벨도 없는 작업대 같다.
실전에서는 도구만으로는 부족하다. 모델이 참고할 읽을거리도 필요하고, 자주 쓰는 작업 흐름을 버튼처럼 꺼내 쓸 방법도 필요하다. 오래 걸리는 작업은 진행 상황을 알려줘야 하고, 어떤 경우에는 서버가 다시 모델이나 사용자에게 되묻거나, 대화 안에 작은 인터페이스를 띄우는 일도 생긴다.
바로 여기서 MCP 서버는 단순한 함수 묶음에서 제대로 된 서비스로 변한다.
왜 툴만으로는 실전 서버가 되기 어려울까
도구는 "행동"에 강하다. 계산하고, 검색하고, 생성하고, 수정하는 데 딱 맞다. 하지만 실전 업무는 행동만으로 굴러가지 않는다. 행동 전에 맥락이 필요하고, 반복되는 작업에는 템플릿이 필요하며, 비동기 작업에는 상태 알림이 필요하다.
예를 들어 사내 개발 도우미 MCP 서버를 만든다고 해보자. create_issue나 search_repo 같은 도구만 있어도 뭔가는 할 수 있다. 하지만 좋은 서버가 되려면 여기서 한참 더 나아가야 한다.
팀의 코딩 규칙 문서를 리소스로 제공할 수 있어야 한다.
"코드 리뷰 시작", "장애 보고서 작성" 같은 프롬프트 템플릿을 제공할 수 있어야 한다.
인덱싱이 끝났거나 목록이 바뀌었을 때 클라이언트에 알릴 수 있어야 한다.
필요하면 모델 호출이나 사용자 추가 입력 같은 상호작용도 이어갈 수 있어야 한다.
이 네 가지가 붙는 순간, 서버는 단순한 공구함이 아니라 일하는 작업실이 된다.
한 장으로 보는 역할 분담
실전 MCP 서버를 이해할 때는 Tools, Resources, Prompts를 서로 헷갈리지 않는 것이 중요하다.
| 요소 | 한마디로 | 누가 주로 고르나 | 잘 맞는 일 |
|---|---|---|---|
| Tools | 일을 한다 | 모델 | 계산, 조회, 생성, 수정, 실행 |
| Resources | 읽을거리를 준다 | 사용자나 클라이언트 | 문서, 설정, 스키마, 참고 데이터 |
| Prompts | 대화의 시작 틀을 준다 | 사용자 | 자주 쓰는 요청, 워크플로 시작, 표준 지시문 |
아주 짧게 요약하면 이렇다. 도구는 손이고, 리소스는 책장이고, 프롬프트는 잘 만들어진 주문서다.
Resources: 서버가 가진 읽기 전용 지식 창고
리소스는 서버가 외부에 꺼내 보여주는 읽기 전용 데이터다. 파일일 수도 있고, 데이터베이스에서 읽어 온 스키마일 수도 있고, 원격 API에서 가져온 문서일 수도 있다. 중요한 점은 "행동"이 아니라 "맥락 제공"이라는 점이다.
쉽게 말해, 도구가 망치라면 리소스는 작업 설명서다. 둘 다 중요하지만 쓰임새가 다르다.
리소스는 언제 쓰면 좋을까
리소스는 다음처럼 모델이 참고하면 좋은 데이터를 노출할 때 특히 빛난다.
프로젝트 README, 아키텍처 문서, 코딩 규칙
데이터베이스 스키마, 테이블 설명, API 명세
자주 바뀌지 않는 사내 정책, 운영 매뉴얼, FAQ
특정 객체를 URI로 식별할 수 있는 읽기 전용 정보
반대로 무언가를 변경하거나 실행해야 한다면 리소스보다 도구가 맞다. 예를 들어 "테이블 구조 보여줘"는 리소스에 가깝고, "새 사용자 생성해줘"는 도구에 가깝다.
리소스의 핵심은 URI와 MIME 타입이다
실전에서 리소스를 잘 설계하려면 두 가지 감각이 필요하다. 하나는 URI이고, 다른 하나는 MIME 타입이다.
URI는 리소스의 주소다. 예를 들어 docs://style-guide/backend 같은 식별자를 만들 수 있다. 이 주소를 보면 클라이언트와 모델은 "아, 이건 백엔드 스타일 가이드 문서구나" 하고 이해하기 쉬워진다.
MIME 타입은 데이터의 형식이다. text/markdown, application/json, text/plain 같은 값이 여기에 해당한다. 이것이 있어야 클라이언트가 내용을 어떻게 다뤄야 할지 감을 잡는다. 마치 택배 상자에 "유리", "냉장", "문서" 스티커를 붙여두는 것과 비슷하다.
정적 리소스와 동적 리소스 템플릿
리소스는 크게 두 방식으로 설계할 수 있다.
첫 번째는 정적 리소스다. 주소가 고정되어 있다. 예를 들어 docs://onboarding처럼 누구나 같은 문서를 읽는다.
두 번째는 동적 리소스 템플릿이다. 주소 안에 변수가 들어간다. 예를 들면 schema://table/{table_name} 같은 형태다. 그러면 schema://table/users나 schema://table/orders처럼 같은 규칙으로 여러 리소스를 제공할 수 있다.
이 방식은 아주 강력하다. 엔드포인트를 무한정 늘리지 않고도, 패턴 하나로 많은 데이터를 노출할 수 있기 때문이다.
예를 들어 이런 식이다.
schema://table/users
schema://table/orders
docs://service/auth
docs://service/billing한눈에 봐도 구조가 읽힌다. 좋은 리소스 설계는 예쁜 주소를 만드는 일이 아니라, 클라이언트와 모델이 길을 잃지 않게 만드는 일이다.
리소스를 설계할 때 자주 하는 실수
초보 서버에서 가장 흔한 실수는 모든 걸 도구로만 내보내는 것이다. 문서를 읽는 일까지 굳이 get_manual 같은 도구로 만들면, 모델은 그것을 행동 도구로 이해하고 불필요하게 호출하려 든다. 읽기 전용 참고 자료는 리소스로 분리해 두는 편이 훨씬 깔끔하다.
또 다른 실수는 리소스 주소를 너무 모호하게 짓는 것이다. data://1, data://2, misc://abc 같은 주소는 처음에는 편해 보여도 나중에 스스로도 헷갈린다. 주소 체계는 조금 딱딱하더라도 의미가 드러나야 한다.
Prompts: 자주 쓰는 대화 흐름을 템플릿으로 만들기
프롬프트는 재사용 가능한 대화 템플릿이다. 여기서 중요한 포인트는 "서버가 모델에게 몰래 밀어 넣는 숨은 지시문"이 아니라, 사용자가 고를 수 있는 정식 기능이라는 점이다.
즉, 좋은 프롬프트는 숨겨진 마법 주문이 아니라 잘 만들어진 단축 버튼에 가깝다.
예를 들어 사용자가 매번 이렇게 입력한다고 해보자.
"다음 코드를 Python 기준으로 리뷰해 주세요. 가독성, 성능, 보안, 유지보수성 관점에서 보고, 우선순위별 개선 항목도 제안해 주세요."
이 요청은 훌륭하지만 매번 쓰기 귀찮다. 이럴 때 서버가 code_review 프롬프트를 제공하면 된다. 사용자는 필요한 코드만 넣고 실행하면 되고, 결과는 더 일관돼진다.
프롬프트는 메시지 묶음이다
프롬프트를 단순 문자열로만 생각하면 절반만 본 것이다. MCP 프롬프트는 여러 메시지로 구성될 수 있다. 사용자 메시지 하나만 반환할 수도 있고, 여러 턴의 흐름을 미리 짜둘 수도 있다.
즉, 프롬프트는 "한 줄 문장"이 아니라 "대화의 출발 패키지"다.
아래는 개념적으로 단순한 프롬프트 정의 예시다.
{
"name": "code_review",
"title": "코드 리뷰 요청",
"description": "코드를 품질, 성능, 보안 관점에서 검토합니다.",
"arguments": [
{
"name": "language",
"description": "프로그래밍 언어",
"required": true
},
{
"name": "focus",
"description": "특별히 중점적으로 볼 항목",
"required": false
}
]
}이렇게 등록해 두면 클라이언트는 목록에서 프롬프트를 보여주고, 사용자는 필요한 인자를 넣어 호출할 수 있다.
프로토콜 수준에서는 어떻게 오갈까
프롬프트를 지원하는 서버는 초기화 시 prompts 기능을 선언한다. 목록 변경 알림까지 보낼 생각이라면 listChanged도 함께 알린다.
{
"capabilities": {
"prompts": {
"listChanged": true
}
}
}그다음 클라이언트는 프롬프트 목록을 요청할 수 있다.
{
"jsonrpc": "2.0",
"id": 1,
"method": "prompts/list"
}특정 프롬프트를 실제로 꺼내 쓸 때는 prompts/get을 보낸다.
{
"jsonrpc": "2.0",
"id": 2,
"method": "prompts/get",
"params": {
"name": "code_review",
"arguments": {
"language": "python",
"focus": "security"
}
}
}서버는 완성된 메시지들을 돌려준다.
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"description": "코드 리뷰 프롬프트",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "다음 Python 코드를 보안 중심으로 리뷰해 주세요."
}
}
]
}
}처음 보면 살짝 딱딱하지만, 핵심은 단순하다. 목록을 보여주고, 하나를 선택하고, 인자를 넣어 펼쳐 본다. 프롬프트는 결국 "서버가 제공하는 재사용 가능한 대화 시작점"이다.
프롬프트를 잘 만드는 요령
프롬프트는 많이 만든다고 좋은 것이 아니다. 애매한 프롬프트 열 개보다, 역할이 분명한 프롬프트 세 개가 낫다.
좋은 프롬프트에는 보통 이런 특징이 있다.
이름만 봐도 쓰임새가 보인다.
필수 인자와 선택 인자가 분명하다.
결과물의 톤이나 형식이 일정하다.
너무 범용적이지 않고, 실제 작업 흐름에 붙어 있다.
예를 들어 analyze 같은 이름은 너무 넓다. 대신 review_pull_request, write_incident_report, summarize_meeting_notes처럼 상황이 드러나는 이름이 훨씬 좋다.
리소스를 프롬프트에 섞으면 훨씬 강해진다
프롬프트만 단독으로 써도 좋지만, 리소스와 결합하면 실전성이 확 올라간다. 예를 들어 write_incident_report 프롬프트가 장애 대응 매뉴얼 리소스를 함께 참고하도록 만들 수 있다. 그러면 모델은 매번 규칙을 다시 설명받지 않아도 된다.
이 조합은 특히 강력하다. 리소스가 기준서를 주고, 프롬프트가 작업 틀을 준다. 기준서와 양식이 동시에 준비되는 셈이다.
Notifications: 서버가 먼저 알려주는 작은 벨
지금까지는 클라이언트가 물으면 서버가 답하는 구조를 주로 봤다. 그런데 실전에서는 서버가 먼저 말 걸어야 할 때가 있다.
예를 들어 이런 경우다.
새 프롬프트가 추가됐다.
리소스 목록이 갱신됐다.
긴 인덱싱 작업이 끝났다.
백그라운드 동기화가 완료됐다.
진행률이 일정 수준 이상 변했다.
이럴 때 필요한 것이 알림, 즉 notification이다.
알림은 왜 중요할까
알림이 없으면 클라이언트는 계속 물어봐야 한다. "끝났어?", "이제 목록 바뀌었어?", "새 문서 들어왔어?" 하고 매번 재조회해야 한다. 서버 입장에서도 비효율적이고, 사용자 경험도 둔해진다.
알림은 이 문제를 해결한다. 서버가 "끝났어요", "목록 바뀌었어요", "지금 70%예요" 하고 알려주면 된다. 이건 대단히 인간적인 기능이다. 일을 시켜 놓고 아무 말도 없는 조수보다, 진행 상황을 중간중간 알려주는 조수가 훨씬 믿음직스럽다.
프롬프트 목록이 바뀌었을 때의 알림
프롬프트 기능을 선언할 때 listChanged를 켰다면, 서버는 프롬프트 목록이 바뀌었을 때 이런 알림을 보낼 수 있다.
{
"jsonrpc": "2.0",
"method": "notifications/prompts/list_changed"
}이 알림을 받은 클라이언트는 프롬프트 목록을 다시 불러와 UI를 갱신할 수 있다. 새 슬래시 명령이 추가됐는데도 화면에는 안 보이는 어색한 상황을 줄일 수 있다.
진행 알림은 길고 무거운 작업에서 특히 유용하다
대형 문서 인덱싱, 리포지터리 스캔, 대량 데이터 로딩 같은 작업은 한 번에 끝나지 않는다. 이럴 때 서버가 조용하면 사용자는 "멈췄나?" 하고 불안해진다.
그래서 긴 작업에는 진행 알림이 필요하다. 숫자 퍼센트가 꼭 아니어도 좋다. 수집 중, 파싱 중, 임베딩 생성 중, 완료 같은 단계형 상태만 있어도 충분히 도움이 된다.
좋은 알림은 친절하지만 수다스럽지 않다. 1%마다 떠드는 서버는 성실한 게 아니라 피곤한 서버다.
알림 설계에서 기억할 원칙
알림 기능을 붙일 때는 몇 가지 원칙을 기억하면 좋다.
상태 변화가 있을 때만 보낸다.
클라이언트가 재연결해도 크게 문제 없게 만든다.
너무 자주 보내지 않는다.
사용자가 이해할 수 있는 단어를 쓴다.
특히 마지막이 중요하다. 내부 코드 상태값인 INDEX_PIPELINE_STAGE_3보다 문서 인덱싱 중이 훨씬 낫다. 서버는 개발자끼리만 대화하는 프로그램이 아니라 사용자 경험의 일부다.
고급 기능 1: Sampling, 서버가 모델에게 다시 부탁하는 법
보통은 모델이 서버의 도구를 호출한다. 그런데 더 복잡한 흐름에서는 반대 방향도 필요하다. 서버가 "이 부분은 모델에게 한 번 더 물어봐야겠는데?" 하고 클라이언트에 모델 호출을 요청하는 방식이 바로 sampling이다.
이건 처음 들으면 조금 뒤집힌 느낌이다. 하지만 생각보다 자연스럽다.
예를 들어 문서 정리 서버를 만든다고 해보자. 서버는 파일을 모으고 구조를 분석할 수는 있다. 하지만 최종 요약문을 아주 자연스럽게 작성하는 일은 모델이 더 잘한다. 그러면 서버는 필요한 자료를 모은 뒤, 클라이언트에게 "이 자료로 요약 생성 한 번 해줘"라고 요청할 수 있다.
쉽게 말해, 도구는 서버가 하고, 고급 글쓰기는 모델이 맡는 분업이다.
이 기능은 강력하지만 조심해서 써야 한다. 모델 호출은 비용도 들고, 사용자 승인도 필요한 경우가 많다. 그래서 sampling은 숨은 지름길이 아니라, 정말 필요할 때 쓰는 고급 회전문 같은 기능으로 보는 편이 좋다.
또 한 가지 기억할 점이 있다. sampling은 사양과 클라이언트 지원 상태에 따라 실제 사용 가능 여부가 다를 수 있다. 그래서 "개념을 이해하는 것"과 "지금 내가 쓰는 호스트가 이를 지원하는가"를 따로 확인하는 습관이 필요하다.
고급 기능 2: Elicitation, 모자란 정보를 다시 묻기
실전 서버는 늘 완전한 입력만 받는 것이 아니다. 사용자가 "장애 보고서 만들어줘"라고만 말할 수도 있다. 그런데 보고서를 쓰려면 서비스 이름, 장애 시각, 영향 범위 같은 정보가 더 필요하다.
이럴 때 억지로 빈칸을 채워 넣기보다, 필요한 값을 다시 물어보는 흐름이 더 낫다. 바로 이런 상황에 쓰는 개념이 elicitation이다.
쉽게 말해 elicitation은 "모르는 건 물어보자" 기능이다.
이건 꽤 중요하다. 초보 서버는 입력이 부족해도 일단 추측해서 진행하려는 경향이 있는데, 실전에서는 그 추측이 사고의 시작점이 되기 쉽다. 특히 운영, 보안, 결제, 배포 같은 민감한 작업에서는 더 그렇다.
좋은 서버는 똑똑하게 실행하기도 하지만, 필요한 순간에는 멈춰 서서 되묻기도 한다.
이 역시 구현과 지원 상태는 사양 버전과 클라이언트에 따라 차이가 날 수 있다. 그래서 서버를 설계할 때는 "이 기능이 개념적으로 필요하다"와 "현재 내 배포 환경에서 실제로 동작한다"를 구분해서 봐야 한다.
고급 기능 3: 상호작용 UI와 MCP Apps라는 다음 단계
텍스트만으로는 설명이 잘 안 되는 작업도 있다. 예를 들어 대시보드, 지도, 복잡한 설정 폼, PDF 뷰어, 코드 변경 비교 화면 같은 것들이다. 이런 경우 대화 안에 작은 인터페이스를 띄워 상호작용하는 방식이 유용하다.
이 흐름이 바로 MCP Apps 같은 확장으로 이어진다. 서버는 도구 결과와 함께 인터랙티브 UI 자원을 연결하고, 호스트는 그 UI를 대화 안에 렌더링한다. 사용자는 탭을 옮겨 다니지 않고도 그래프를 보고, 옵션을 고르고, 다음 도구 호출을 이어갈 수 있다.
물론 이 단계는 이제 꽤 본격적이다. 작은 서버 하나 띄우던 시절과는 분위기가 다르다. 하지만 중요한 감각은 지금 익혀 두면 된다. MCP의 목표는 단순히 텍스트 몇 줄을 주고받는 것이 아니라, 대화 속에서 실제 작업 환경을 점점 더 풍부하게 만드는 데 있다.
이 주제는 다음 장에서 클라이언트와 앱 통합을 다룰 때 더 본격적으로 이어진다.
실전 예시: 문서 도우미 서버를 한 단계씩 업그레이드해 보기
이제 개념을 한데 모아 보자. 우리가 가진 초보 서버가 원래는 search_docs라는 도구 하나뿐이었다고 해보자. 이 서버를 실전 서버로 키우는 과정은 대략 이런 식이다.
1단계: 도구만 있는 초보 서버
사용자는 문서를 찾을 수 있다. 하지만 어떤 문서를 먼저 읽어야 하는지, 요약 형식은 어떤지, 최신 상태인지는 알기 어렵다.
2단계: 리소스 추가
docs://architecture/overview, docs://runbook/deploy, docs://style/api 같은 리소스를 노출한다. 이제 모델과 사용자는 필요한 기준 문서를 직접 참조할 수 있다.
3단계: 프롬프트 추가
summarize_doc, write_release_note, troubleshoot_incident 같은 프롬프트를 제공한다. 이제 사용자는 빈손으로 시작하지 않고, 잘 짜인 작업 틀 위에서 시작할 수 있다.
4단계: 알림 추가
문서 인덱싱이 끝나면 목록 변경 알림을 보내고, 대규모 동기화 중에는 진행 상태를 알려준다. 서버가 살아 있다는 느낌이 생긴다.
5단계: 고급 상호작용 추가
입력이 부족하면 다시 묻고, 필요한 순간에는 모델에게 요약 생성을 요청하고, 나중에는 문서 비교 UI까지 붙인다. 이쯤 되면 서버는 더 이상 작은 도구 상자가 아니다. 진짜 서비스다.
이 변화에서 핵심은 기능 수가 아니라 역할의 균형이다. 읽을 것, 할 것, 시작할 틀, 상태 알림, 상호작용이 서로 연결되기 시작하면 서버의 품질이 한 단계 올라간다.
구현할 때 놓치기 쉬운 디테일
고급 기능은 멋져 보이지만, 기본기를 놓치면 금방 미끄러진다. 특히 아래 부분은 꼭 챙겨야 한다.
리소스는 읽기 전용이라는 원칙을 지키기
리소스가 사실상 쓰기나 실행의 통로가 되면 역할이 흐려진다. 리소스는 참고 자료로 남기고, 변화가 생기는 동작은 도구로 분리하는 편이 안전하다.
프롬프트 인자 검증하기
프롬프트는 예쁘게 보이기만 하면 되는 기능이 아니다. 필수 인자가 빠졌는지, 값 형식이 맞는지, 허용되지 않은 문자를 넣지 않았는지 확인해야 한다. 프롬프트는 대화 템플릿이지만, 결국 입력을 받아 서버가 조립하는 기능이기 때문이다.
알림은 이벤트이지 로그가 아니다
로그를 알림처럼 뿌리기 시작하면 클라이언트는 금세 시끄러워진다. 알림은 사용자나 UI가 반응해야 할 변화에만 쓰는 것이 좋다.
프롬프트와 도구를 섞어 쓰되 역할을 섞지는 않기
예를 들어 create_incident_report가 실제 보고서 파일까지 자동 생성하는 도구인지, 아니면 보고서 초안 작성을 위한 프롬프트인지 헷갈리면 사용자는 물론 모델도 혼란스러워진다. 프롬프트는 시작 틀, 도구는 실행이라는 경계를 유지하는 편이 좋다.
초보 서버가 실전 서버가 되는 체크리스트
마지막으로, 지금 만들고 있는 서버가 어느 단계쯤 왔는지 점검해 보자.
도구 설명이 충분히 구체적인가
읽기 전용 참고 자료를 리소스로 분리했는가
반복 작업을 프롬프트 템플릿으로 제공하는가
목록 변경이나 긴 작업 완료를 알릴 수 있는가
입력이 부족할 때 안전하게 되묻는가
모델 호출이 필요한 부분과 서버가 직접 처리할 부분을 구분했는가
이 질문에 하나씩 "예"가 붙기 시작하면, 당신의 서버는 확실히 초보 티를 벗고 있다.
마무리
이번 장에서 우리는 MCP 서버를 한 단계 성장시키는 핵심 요소들을 살펴봤다. 도구만으로는 일을 시킬 수는 있어도, 좋은 경험을 만들기는 어렵다. 리소스가 들어오면 서버는 맥락을 제공할 수 있고, 프롬프트가 들어오면 반복 작업을 표준화할 수 있다. 알림이 붙으면 서버는 더 이상 무뚝뚝한 기계가 아니라 상황을 알려주는 협업 파트너가 된다. 여기에 sampling, elicitation, 상호작용 UI 같은 고급 기능까지 얹히면, 서버는 진짜 서비스의 얼굴을 갖기 시작한다.
이 장부터가 중요한 분기점이다. 이제 당신의 MCP 서버는 단순한 함수 묶음이 아니다. 읽을거리와 작업 흐름, 상태 변화와 상호작용을 함께 설계하는 작은 플랫폼이 된다.
그리고 다음 장에서는 이 서버를 이제 반대편에서 바라보게 된다. 서버만 잘 만들어서는 충분하지 않다. 그 서버를 받아 쓰는 클라이언트와 앱에 어떻게 자연스럽게 통합할지도 알아야 한다. 작업실이 준비됐다면, 이제는 그 작업실의 문과 창문을 어떻게 낼지 고민할 차례다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.