개요
- MCP(Model Context Protocol)를 완성품부터 만들지 않고, 구조와 동작 원리부터 하나씩 이해하기 위해 정리한 글이다.
- 최종 목표는 “증상을 말하면 관련 테스트 시나리오를 찾아 여러 플랫폼에서 실행하고 비교 보고서를 내는 MCP”이다.
- 이 글에서는 전체 그림 → 언어 선택 → SDK 없이 맨몸으로 프로토콜 이해하기까지 다룬다.
MCP는 어떤 문제를 푸는가?
모델은 글자만 낼 수 있다
- Claude 같은 모델이 할 수 있는 일은 하나뿐이다. 글자를 받아서 글자를 내놓는 것.
- 그런데 Claude Code는 실제로 파일을 읽고 명령을 실행한다. 순서는 이렇다.
- 호스트(Claude Code)가 모델에게 “너는 이런 도구를 쓸 수 있어”라며 도구 목록을 같이 보낸다. 각 도구에는 이름, 설명, 입력 형식이 적혀 있다.
- 모델은 답 대신 “
Read도구를file_path=/a/b.txt로 불러 줘”라는 글자를 낸다. - 호스트가 그 글자를 읽고 실제로 파일을 연다. 결과를 다시 모델에게 넘긴다.
- 모델이 결과를 보고 다음 행동을 정한다.
- 행동은 전부 호스트가 하고, 모델은 무엇을 할지 고르기만 한다.
문제: 도구를 붙이는 방법이 제각각이었다
- 기본 도구(
Read등)는 호스트 안에 들어 있지만, 회사 내부 시스템 조회 같은 도구는 바깥에서 붙여야 한다. - MCP 이전에는 호스트마다 플러그인 방식이 달랐다. Claude Desktop, Cursor, Codex에 같은 도구를 붙이려면 세 번 따로 만들어야 했다.
- 도구 N개 × 호스트 M개 = N×M번 만들어야 하는 구조.
MCP의 답
- 도구를 호스트 밖의 독립 프로그램(MCP 서버)으로 뺀다.
- 호스트와 서버가 주고받는 말(“어떤 도구가 있니?”, “이 도구를 이 입력으로 실행해 줘”, “결과는 이거야”)의 형식을 하나로 정한다. 이것이 MCP다.
- 서버는 한 번만 만들면, MCP를 아는 호스트 어디에서든 붙는다.
꼭 기억할 것
- MCP 서버 안에는 AI가 없다. JSON 요청을 받아 일을 하고 JSON으로 답하는 평범한 프로그램이다.
- 어떤 도구를 언제 부를지, 결과가 관련 있는지 판단은 전부 호스트 쪽 모델이 한다.
- 서버는 받은 입력대로 기계적으로 처리한다. 검색 기능을 넣어도 서버의 검색은 글자 일치 수준이고, 동의어를 떠올리거나 결과를 고르는 건 모델이다.
iOS로 치면: App Intents. 앱이 입력 형식이 정해진 동작을 공개하면 Siri·단축어(호스트)가 앱 내부를 모른 채 그 동작을 부른다.
세 역할: 호스트 · 클라이언트 · 서버
- 호스트: 사용자가 쓰는 앱. Claude Code, Claude Desktop, Codex 등. 모델과 대화를 관리하고, 도구 실행 허락을 받는다.
- 서버: 내가 만들 프로그램. 호스트와 별도 프로세스다. 도구 목록을 알려 주고, 호출이 오면 실행한다.
- 클라이언트: 호스트 안에 있는 연결 담당 부품. 따로 설치하는 게 아니다. 서버 하나당 클라이언트 하나가 짝을 이룬다. MCP 형식으로 메시지를 주고받는 일만 한다.
- 내가 만드는 것은 서버뿐이다.
- “서버”는 위치가 아니라 역할 이름이다. 내 PC에서 도는 작은 프로세스라도 도구를 제공하면 서버다.
연결 방식 두 가지
1. 로컬 (stdio)
claude mcp add로 “이 명령을 실행하면 서버가 뜬다”고 등록한다.- Claude Code가 시작할 때 그 명령을 자식 프로세스로 띄운다.
- 클라이언트는 서버의 stdin에 JSON을 한 줄씩 쓰고, 서버의 stdout에서 답을 읽는다.
- Claude Code를 끄면 서버 프로세스도 함께 끝난다.
- 네트워크 포트를 열 필요조차 없다. 터미널에서 입력을 읽고 출력을 쓰는 프로그램과 같다.
- 예:
npx -y xcodebuildmcp@latest mcp→ npm 패키지를 받아 MCP 서버 모드로 실행하는 명령.
2. 원격 (HTTP)
- 서버는 이미 다른 기계에서 돌고 있고, 호스트는 URL로 HTTP 요청만 보낸다. 내 PC에서는 아무것도 실행되지 않는다.
- 누구나 주소를 알 수 있으므로 인증(OAuth 등)이 필요하다.
- 주고받는 메시지 형식(MCP)은 두 방식이 같다. 바뀌는 건 통로뿐.
직접 보는 법
- 터미널에서
claude mcp list→ 붙어 있는 서버 목록이 나온다.- 실행 명령이 보이면 로컬(stdio) 서버, URL이 보이면 원격 서버.
- 앞에
claude.ai가 붙은 항목은 claude.ai 계정(조직)에 등록된 커넥터를 가져온 것이다. 내가 설치한 게 아니다. Needs authentication은 원격 서버에 아직 로그인하지 않았다는 뜻.
함정
- 서버는 대화 내용을 전혀 모른다. 서버가 받는 건 도구 호출에 담긴 인자 몇 개가 전부다. 그래서 필요한 정보는 모두 인자로 받아야 한다.
npx ...@latest는 실행할 때마다 최신 버전을 받는다. 동작이 갑자기 바뀔 수 있고 남의 새 코드를 확인 없이 실행하게 된다. 배포할 때는 버전을 고정한다.
iOS로 치면: stdio는 앱이 XPC 서비스를 별도 프로세스로 띄우는 것, 원격은 URLSession으로 서버 API를 부르는 것.
스킬과 MCP
둘의 성격
- 스킬: 모델에게 주는 설명서. 필요할 때 대화에 불러오는 지시문이다. 스크립트를 함께 넣을 수 있지만, 실행은 호스트가 가진 손(Bash)을 빌린다.
- MCP: 호스트에 새 손을 달아 준다. 규칙과 환경이 갖춰진 작은 백엔드를 띄우고 JSON으로 통신한다.
갈림길은 하나
- “그 호스트에 쓸 만한 손이 이미 있는가?”
- 나 혼자, Claude Code에서(Bash가 있음) 쓰는 도구라면 스킬 + 스크립트로 충분한 경우가 많다. 더 단순하다.
- 여러 사람이, 여러 앱에서, 안전하게 써야 한다면 MCP가 필요해진다.
호스트가 하나뿐이어도 MCP가 남는 이유
- 셸이 없는 호스트가 있다. Claude Desktop, claude.ai 웹은 Bash를 실행할 수 없다. 이들이 외부 도구를 붙이는 방법이 MCP다.
- 계약이 생긴다. 스크립트는 모델이 매번 명령줄을 조립한다. MCP는
inputSchema로 입력 형식을 고정하고, 모델은 빈칸만 채운다. - 안전 경계가 생긴다. Bash를 열면 모델은 원칙적으로 아무 명령이나 실행할 수 있다. MCP 도구는 서버가 열어 준 동작만 한다.
- 오래 살아 있는 프로세스다. 스크립트는 매번 새로 시작한다. 서버는 세션 내내 떠 있어서 DB 연결, 캐시, 디버거 세션 같은 상태를 유지할 수 있다.
예: XcodeBuildMCP는 왜 Bash로 xcodebuild를 안 부르나
- 수천 줄 빌드 로그를 서버가 받아 에러·경고만 추려 돌려준다. (컨텍스트 절약)
- 프로젝트·스킴·시뮬레이터 설정을 기억한다.
- LLDB 디버거처럼 여러 번의 호출에 걸쳐 연결을 유지해야 하는 작업이 가능하다. ← 가장 결정적
Claude Desktop의 사내망 문제
- Claude Desktop의 코드 실행 샌드박스는 사설·사내 주소에 접속할 수 없다. 샌드박스가 사내망으로 들어가는 발판이 되지 않게 막은 의도된 설계다. (Anthropic 공식 문서)
- 로컬 MCP 서버는 사용자 PC에서 돌기 때문에 PC가 사내망에 있으면 서버도 닿는다.
- 그래서 사내망에만 있는 서버를 Desktop에서 쓰려면 스킬로는 물리적으로 불가능하고 MCP가 필요하다.
- 주의: 위 문서는 Cowork 기능 기준이다. 일반 채팅 스킬도 같은지는 직접 호출해 보고 확인하는 게 가장 확실하다.
MCP는 일종의 규약
- HTTP가 “브라우저와 웹 서버가 이렇게 말하자”는 규약이듯, MCP는 “AI 호스트와 도구 서버가 이렇게 말하자”는 규약이다.
- 차이: MCP는 HTTP 위에 올라탈 수 있다. HTTP·stdio는 통로, MCP는 그 위에서 오가는 대화 규칙. 경쟁이 아니라 위아래 관계다.
iOS로 치면: Xcode가
sourcekit-lsp와 대화하는 LSP(Language Server Protocol)와 같은 구조. 실제로 MCP는 LSP를 본떠 만들었다.
메시지의 생김새
- MCP는 메시지 모양을 새로 만들지 않고 JSON-RPC 2.0을 그대로 쓴다. “JSON으로 함수를 원격 호출하는 법”을 정한 작은 규약이다.
메시지는 딱 세 종류
1. 요청: “이거 해 줘, 답 줘”
1 | |
jsonrpc: 항상"2.0"id: 이 요청의 번호표method: 무엇을 하라는지 (함수 이름)params: 인자
2. 응답: “아까 7번 요청, 결과 여기”
1 | |
- 요청과 같은
id로 짝을 맞춘다. - 성공이면
result, 실패면error. 둘 중 하나만 온다.
3. 알림: “알려만 줄게, 답은 필요 없어”
1 | |
id가 없다. 그래서 받는 쪽은 답하지 않는다. 답하려 해도 붙일 번호표가 없다.
id 덕분에 가능한 것
- 요청 1번, 2번을 연달아 보냈는데 2번 답이 먼저 와도 id로 짝을 맞추니 헷갈리지 않는다.
- 그래서 서버는 요청을 동시에 처리해도 된다. 느린 작업을 기다리지 않고 빠른 것부터 답할 수 있다.
- 규칙: 아직 답이 안 온 요청끼리는 id가 겹치면 안 된다. (호스트가 지킨다)
함정: stdio에서는 메시지 하나 = 한 줄
- 줄바꿈이 메시지의 끝이다. JSON을 보기 좋게 여러 줄로 나눠 보내면 안 된다.
- 서버가
console.log("디버그")처럼 stdout에 아무 글자나 찍으면 호스트가 JSON으로 읽으려다 깨진다. - stdout은 프로토콜 전용, 로그는 stderr로.
iOS로 치면: URLSession의 completion handler가 “어떤 요청의 답인지” 아는 것. JSON-RPC는 그 짝을
id로 직접 맞춘다.
JSON-RPC는 어디서 쓰나?
- 앱·웹 개발에서는 REST를 주로 써서 낯설다. JSON-RPC는 프로그램끼리 뒤에서 대화하는 곳에 있다.
- LSP: 에디터 자동완성 뒤에서
{"method":"textDocument/completion",...}이 오간다. - 블록체인 노드: 잔고 조회가
{"method":"eth_getBalance",...} - Chrome DevTools Protocol: 정확히 2.0은 아니지만
id·method·params를 쓰는 사촌.
- LSP: 에디터 자동완성 뒤에서
REST와의 차이
- REST: 무엇을 할지가 주소와 HTTP 메서드에 있다. →
GET /scenarios/1234 - JSON-RPC: 무엇을 할지가 본문의
method에 있다. →{"method":"get_scenario","params":{"id":"1234"}} - 그래서 JSON-RPC는 URL이 없는 통로(stdin/stdout)에서도 돈다. MCP와 LSP가 JSON-RPC를 고른 이유. 로컬(stdio)과 원격(HTTP)을 같은 메시지 모양으로 처리할 수 있다.
iOS로 치면: REST는 엔드포인트마다 URLRequest를 따로 만드는 방식, JSON-RPC는
enum Command하나를 정의해 두고case만 바꿔 보내는 방식.
서버가 내놓는 세 가지
- 서버가 호스트에 내놓을 수 있는 것은 세 종류이고, 가르는 기준은 “누가 꺼내 쓰기로 결정하느냐” 하나다.
Tools (도구): 모델이 결정
- 모델이 대화 중에 “이거 불러야겠다”고 판단해서 호출한다.
- 실행하고, 무언가를 하고, 결과를 낸다.
- 예: “1234번 시나리오 봐 줘” → 모델이 스스로
get_scenario호출
Resources (리소스): 사용자나 앱이 결정
- 읽기 전용 자료. 주소(URI)가 붙는다. 예:
scenario://1234 - 모델이 알아서 꺼내는 게 아니라, 사용자가 대화에 첨부한다. Claude Code에서는
@자동완성 목록에 나온다. - 사용자가 위치나 가져오는 법을 알아야 하는 게 아니다. 가져오는 일은 여전히 서버가 하고, 사람은 목록에서 고르기만 한다.
Prompts (프롬프트): 사용자가 결정
- 미리 만들어 둔 질문 틀. Claude Code에서는
/mcp__서버이름__틀이름슬래시 명령으로 나타난다. - 사실상 스킬을 서버가 함께 배포하는 것과 비슷하다.
어느 쪽으로 내놓나?
- 비서에 비유하면
- Tool: 비서에게 “1234번 서류 찾아봐”라고 말한다. 비서가 알아서 찾고, 필요하면 다른 서류도 더 본다.
- Resource: 내가 서류함에서 1234번을 직접 집어 비서 책상에 올려 둔다. 비서는 올려 준 것만 본다.
- 기준: 대화 중에 필요해지는 순간이 생기는 자료 → Tool / 대화 전에 사람이 미리 골라 둘 자료 → Resource
- 사용자가 첨부 방법을 모를 수도 있으니 Tool이 기본, Resource는 덧붙이는 편의 기능.
함정
- Tools는 거의 모든 호스트가 지원하지만, Resources·Prompts는 호스트마다 지원 범위가 다르다. 그래서 대부분의 MCP 서버는 Tools만 내놓는다.
iOS로 치면: Tools는 Siri가 알아서 부르는 App Intent, Resources는 공유 시트로 넘기는 파일, Prompts는 사용자가 직접 누르는 단축어.
곁가지: URI와 URL
- URI: 무언가를 가리키는 이름.
스킴:나머지모양이면 전부 URI다. (https://...,mailto:...,scenario://1234, 딥링크myapp://...) - URL: URI 중에서 어디서 어떻게 가져오는지까지 알려 주는 것. 모든 URL은 URI다.
- 사실 둘의 경계는 애매하고, 표준 문서도 엄격히 나눌 실익이 없다고 본다.
- MCP가 “URI”라고 쓰면 “http가 아니어도 된다, 서버만 해석하는 이름이어도 된다”는 뜻으로 읽으면 된다.
언어와 런타임: TypeScript + Node
고르기 전에 알아 둘 사실
- MCP는 규약이다. stdin으로 JSON 한 줄을 읽고 stdout으로 한 줄을 쓸 수 있으면 어떤 언어로든 서버를 만들 수 있다.
- 그래서 언어 선택은 “되느냐”가 아니라 “상황에 무엇이 유리하냐”의 문제다.
고르는 기준
- 공식 SDK 성숙도: TypeScript와 Python이 1등급. 공식 예제, 디버깅 도구(Inspector), 새 기능이 가장 먼저 나온다.
- 사용자 PC에서 돌릴 수 있나: Node는 이미 깔려 있는 경우가 많다. Python은 Windows 기본 설치가 아니다(요즘은
uvx가npx역할). Go/Rust는 실행 파일 하나. Swift는 사실상 Mac 전용. - 학습 목표와 겹치나
- 기존 팀 서버와 합칠 수 있나
- 현실적으로는 TypeScript와 Python이 양대 산맥. Python은
@mcp.tool()한 줄로 도구를 만드는 FastMCP 방식이 간단하고, TypeScript는 기준 구현이자npx한 줄 배포가 쉽다. - 결정: TypeScript + Node
Node란?
- JavaScript는 원래 브라우저 안에서만 돌았다. Chrome 안의 JS 엔진이 V8.
- 브라우저는 보안 때문에 파일, 다른 프로그램 실행, stdin/stdout을 막아 둔다.
- Node.js = V8만 떼어 내고 파일·네트워크·프로세스·stdin/stdout을 붙인 것. JavaScript로 일반 프로그램(서버, CLI, MCP 서버)을 만들 수 있게 됐다.
- clang 같은 컴파일러가 아니라
python명령, JVM 같은 실행기(런타임)다. Node가 없으면 프로그램이 안 돈다. 그래서 사용자 PC에 Node가 있어야 한다.
iOS로 치면: iOS의
JavaScriptCore가 V8과 같은 역할의 엔진. Node는 그런 엔진을 앱 밖으로 꺼내 명령줄 실행기로 만든 것.
npm
- Node Package Manager. 세 가지를 함께 가리킨다.
- 창고: npmjs.com. MCP SDK도
@modelcontextprotocol/sdk로 올라가 있다. - 명령어:
npm install 패키지,npm run 이름 - 프로젝트 파일:
package.json(설명서),node_modules/(받은 패키지),package-lock.json(버전 고정 기록)
- 창고: npmjs.com. MCP SDK도
npx: 패키지를 설치 없이 바로 실행.- 함정:
node_modules/는 수백 MB → git에 올리지 않는다. 누구나 올리는 창고라 버전 고정과 출처 확인이 중요하다.
iOS로 치면:
package.json=Package.swift,package-lock.json=Package.resolved/Podfile.lock,node_modules/=Pods/
TypeScript와 tsc
- TypeScript = JavaScript + 타입. 실행되는 순간 타입은 완전히 사라진다.
- Node도 브라우저도 원래는 JavaScript만 읽는다. 그래서 타입 표기를 걷어 내는 단계가 필요하다.
- tsc(TypeScript Compiler): 기계어가 아니라 TS → JS로 코드를 바꾸는 도구(트랜스파일러). 하는 일은 두 가지.
- 타입 검사
- 변환: 타입 표기를 걷어 내고
.js생성
1 | |
- 최신 Node(23.6 이상, 24 포함)는
.ts를 플래그 없이 바로 실행한다. 단, 타입만 지우고 검사는 안 한다. (tsc의 2번만 대신)- 그래서
const name: number = "mcp"도 오류 없이 실행된다. - 요즘은 실행은 Node로 바로, 검사는
tsc --noEmit으로 따로 돌리는 경우가 많다.
- 그래서
- 함정:
enum처럼 타입이면서 실제 값도 만드는 문법은 지울 수 없어서 Node 직접 실행에서 오류가 난다. 대신"a" | "b"같은 문자열 묶음을 쓴다.
타입만 있는 거면 왜 쓰나?
- 실행할 때가 아니라 쓸 때 돕는다.
- 에디터가 실시간 검사 → 치는 순간 빨간 밑줄
- 자동완성
- 코드가 커져도 안전하게 고칠 수 있다 (고칠 곳이 전부 빨갛게 표시)
- MCP 함정: 타입은 내 코드 안에서만 지켜진다. 호스트가 보낸 JSON 인자는
scenario_id: string이라고 써 둬도 숫자나null이 올 수 있다. 그래서 MCP TypeScript SDK는 zod로 들어온 값을 실행 중에 한 번 더 검사한다.
iOS로 치면: Objective-C의
NSArray<NSString *>. 컴파일러는 경고하지만 실행 중엔 아무 객체나 들어 있을 수 있다.
맨몸으로 서버 만들기 (SDK 없이)
1. 서버의 귀: stdin에서 한 줄씩 읽기
1 | |
readline: 입력을 줄바꿈 단위로 잘라 준다. “한 줄 = 메시지 하나” 규칙을 처리한다.process.stdin: 터미널에서 실행하면 키보드, Claude Code가 띄우면 Claude Code가 쓰는 통로.rl.on("line", ...): 줄이 올 때마다 함수 호출. 프로그램이 끝나지 않고 줄이 올 때까지 기다리는 구조가 된다.console.log→ stdout(답장 통로),console.error→ stderr(로그 통로)- 파이프로 넣어 보기:
printf 'a\n{"x":1}\n' | node echo.ts← Claude Code가 실제로 하는 일과 같은 모양.
iOS로 치면:
NotificationCenter.addObserver에 클로저를 등록해 두는 구조.
2. 서버는 언제 끝나나?
- 입력(stdin)이 닫히는 순간 끝난다. (Ctrl+D든 파이프가 닫히든 EOF)
- readline이 EOF를 보고
"close"호출 - Node가 더 기다릴 게 없다고 판단
- 스스로 종료
- readline이 EOF를 보고
- MCP 규격의 stdio 종료 방법도 이것이다. 호스트는 먼저 서버의 stdin을 닫고, 안 끝나면 SIGTERM, 그래도 안 끝나면 SIGKILL.
- 함정: stdin이 닫혀도 타이머나 네트워크 연결을 붙잡고 있으면 Node가 안 끝난다. → Claude Code를 꺼도 서버가 혼자 남는다.
"close"때 정리할 것을 정리한다.
3. 카운터: JSON으로 받고 JSON으로 답하기
카운터가 하는 일은 네 가지다.
- 줄을 JSON으로 연다. 안 열리면 “Parse error”
- 요청인지 알림인지 가른다.
id가 없으면 알림 → 답하지 않음 method를 보고 나눠 준다. 모르는 method면 “Method not found”- 받은
id를 그대로 붙여 JSON 한 줄로 답한다.
1 | |
실행 결과:
1 | |
-32700(Parse error),-32601(Method not found)은 JSON-RPC 규격이 정한 오류 번호다.- JSON이 깨지면 번호표를 읽을 수 없으니
id는null. JSON.stringify는 줄바꿈 없는 한 줄을 만들어 주므로 끝에\n만 붙이면 된다.- 이것이 MCP 서버의 뼈대 전부다. SDK가 해 주는 일의 핵심도 이 카운터다. 남은 건
ping자리에 MCP가 정한 method(tools/list,tools/call등)를 채우는 일. - (참고:
ping은 최신 규격에서 빠졌다. 여기선 예시로만 썼다.)
iOS로 치면:
JSONSerialization으로 연 뒤switch method { case "ping": ... default: ... }
첫 대화: 버전과 기능 맞추기
왜 맞춰야 하나
- 호스트와 서버는 다른 사람이, 다른 시기에 만든다. 그래서 알아야 할 것이 두 가지.
- 어느 판의 규격으로 말할지: 버전은
2025-11-25처럼 날짜. - 서로 무엇을 할 줄 아는지: Tools만 주는지, Resources도 주는지 → capabilities
- 어느 판의 규격으로 말할지: 버전은
옛 방식: 처음에 한 번 인사하고 기억한다 (stateful)
전화 통화처럼 처음에 소개하고, 끊을 때까지 그 약속을 기억한다.
- 호스트 → 서버:
initialize
1 | |
- 서버 → 호스트: 응답 (
"tools":{}= “저는 도구를 줄 수 있어요”)
1 | |
- 호스트 → 서버:
notifications/initialized알림. “인사 끝, 이제 본론.”
새 방식: 요청마다 명함을 붙인다 (stateless)
- 2026-07-28판 규격에서
initialize절차가 없어졌다. - 편지처럼 인사 단계 없이, 모든 요청의
_meta에 내 버전과 capabilities를 매번 적어 보낸다. - 서버 정보가 궁금하면
server/discover로 물어볼 수 있지만 필수는 아니다. - 왜 바꿨나: 원격 서버가 여러 대로 나뉘어 있으면, 1번 서버와 인사했는데 다음 요청이 2번 서버로 갈 수 있다. 요청마다 명함이 붙어 있으면 아무 서버나 바로 답할 수 있다. 로컬 stdio 서버에는 큰 차이가 없다.
과도기
- 서버는 두 방식을 다 받아 줄 수 있다.
_meta가 있으면 새 방식,initialize가 오면 옛 방식. - stdio 호스트는 먼저
server/discover를 보내 보고, 정상 답이면 새 방식, “Method not found”나 무응답이면 옛 방식(initialize)으로 넘어간다. - 지금 깔려 있는 서버 대부분은 옛 방식으로 만들어졌다.
iOS로 치면: 옛 방식은 로그인 후 세션 쿠키로 버티는 URLSession, 새 방식은 요청마다 토큰 헤더를 붙이는 방식.
곁가지: 세션 쿠키 vs 토큰
- Swift에서 토큰 방식은 요청마다
Authorization헤더를 붙인다.
1 | |
- 실무에서는: 붙이는 곳을 래퍼 한 곳으로 모으고, 토큰은 Keychain에 저장하고, 401이면 리프레시 토큰으로 갱신 후 재시도(동시 갱신은
actor로 한 번만). - 사실 세션 쿠키도 매 요청마다 간다. 진짜 차이는 안에 든 것.
- 세션 쿠키: 번호표(
session=8f3a...)뿐. 서버가 “8f3a번이 누구였지?”를 찾아봐야 한다 → stateful - 토큰(JWT 등): “누구인지, 언제까지 유효한지”가 직접 적혀 있고 서명이 있다. 서명만 확인하면 된다 → stateless
- 번호표는 발급한 창구에서만 의미가 있고, 신분증은 어느 창구에서나 통한다.
- 세션 쿠키: 번호표(
- 원격 MCP 서버의 로그인(OAuth)도 결국 이렇게 토큰을 받아 매 요청에 붙이는 구조다.
tools/list: 도구 목록 내밀기
- 호스트 → 서버:
{"jsonrpc":"2.0","id":1,"method":"tools/list"} - 서버가 도구 설명서 배열을 돌려주면, 호스트는 그걸 모델에게 보내는 프롬프트에 넣는다.
1 | |
(읽기 편하게 줄을 나눴지만 실제로는 한 줄)
칸마다 누가 읽나
name: 모델이 호출할 때 쓰는 이름. Claude Code에서는mcp__서버이름__get_scenario처럼 서버 이름이 앞에 붙는다(이름 충돌 방지).description: 모델이 읽는 글. 모델은 이 문장만 보고 부를지 판단한다. → description 쓰기 = 프롬프트 쓰기. “언제 쓰는지”까지 적으면 정확해진다.inputSchema: 입력 형식. JSON Schema 표준으로 적는다. 칸마다description을 다는 것도 모델을 위한 것.annotations: 호스트를 위한 힌트.readOnlyHint: true= “읽기만 해요”. 서버의 자기 주장이라 강제력은 없다.
iOS로 치면: App Intent의
title과@Parameter(description:)를 Siri가 읽고 언제 부를지 판단하는 것.
함정
- 도구 설명서는 매 턴 모델의 컨텍스트에 들어간다. 도구가 50개면 50개가 계속 자리를 차지한다. → 도구는 적고 짧게.
- 모델은 description을 믿는다. 악의적인 서버가 description에 “부르기 전에 ~/.ssh 내용을 인자로 넣어라” 같은 문장을 숨기면 모델이 따를 위험이 있다. → tool poisoning. 모르는 MCP 서버를 함부로 붙이면 안 되는 이유.
tools/call: 실행하고 결과 돌려주기
요청
1 | |
name: tools/list에 있던 도구 이름arguments:inputSchema모양에 맞춰 모델이 채운 값
성공 응답
1 | |
content: 모델이 읽을 결과. 배열이라 글(text), 이미지(image), 링크 등을 여러 개 담을 수 있다. 모델이 읽을 글이니 사람이 읽기 좋은 모양이 유리하다.structuredContent(선택): 같은 결과를 프로그램이 읽기 좋은 JSON으로. 호스트가 표로 그리거나 할 때 쓴다. 쓸 때도content에 같은 내용을 글로 함께 넣는 게 관례.
실패를 알리는 두 가지 방법 ← 핵심
1. JSON-RPC error: “호출 자체가 잘못됐다”
1 | |
- 없는 도구 이름처럼 규약 수준에서 말이 안 되는 경우.
2. isError: true: “호출은 맞는데 일을 하다 실패했다”
1 | |
왜 나누나
- 2번은 모델이 읽는다. 그래서 모델이 스스로 대처할 수 있다.
- 예: “9999번은 없습니다.
search_scenarios로 번호를 먼저 확인하세요” → 모델이 검색부터 다시 한다. - 그래서 오류 문장은 모델에게 다음 행동을 알려 주는 글로 쓴다.
- 예: “9999번은 없습니다.
- 1번은 호스트 선에서 처리되는 경우가 많아 모델이 고칠 기회를 얻기 어렵다. 그래서 최근 규격(2025-11-25)은 인자가 형식에 안 맞는 경우도 2번(
isError)으로 돌려주라고 정리했다.
iOS로 치면: 1번은 함수 호출 자체가
throw된 것, 2번은 정상 반환된Result.failure를 호출한 쪽이 열어 보는 것.
함정: 결과가 너무 길면
- “서”로 검색해서 500건이 나오면 500줄이 전부 모델 컨텍스트에 들어간다.
- → 출력 상한. 예: 20건까지만 + “480건 더 있음. 키워드를 좁히세요”
1단원 한 줄 요약
- 서버가 하는 일은 “한 줄 받기 → 열기 → method 보고 나누기 → 같은 id로 한 줄 답하기”뿐이고, MCP는 그 method들의 이름과 모양을 정해 둔 것이다.
실전 설계에서 나온 원칙들
- 만들려는 것: 증상을 말하면 관련 시나리오를 찾고, 지정한 플랫폼(iOS/Android/웹/Windows)과 버전에서 실행해 비교한 뒤 보고서를 내는 MCP.
- 설계하면서 정리된 원칙들:
판단은 모델, 손은 서버
- “이상한지 검토”, “보고서 작성”, “동의어로 검색어 바꾸기”, “관련 있는 후보 고르기” → 모델
- 검색(글자 일치), 조회, 시뮬레이터 실행, 캡처 → 서버
- 실행 결과의
content에 캡처 이미지를 담으면 모델이 직접 보고 판정한다.
도구는 잘게, 모드는 조합으로
- 검색 / 상세 조회 / 실행 / 서식 생성을 따로 두면, “시나리오만 알려줘”는 모델이 실행 도구를 안 부르면 끝이다. 옵션을 따로 만들 필요가 없다.
- 모드를 사용자가 확실히 고르게 하고 싶으면 Prompts(
/qa-find,/qa-test등)로 제공한다.
추측하면 안 되는 값은 두 겹으로 막는다
- 필수 칸으로 둬도 모델이 알아서 채워 버릴 수 있다.
- ① description에 “사용자가 말하지 않았으면 추측하지 말고 물어볼 것” ② 서버가 실행 도중 사용자에게 직접 묻는 elicitation (호스트 지원은 제각각)
- 허용 값은
enum으로 못 박는다. 예:"platforms": {"type":"array","items":{"enum":["ios","android","web","windows"]}}
매번 바뀌는 값은 인자, 설치할 때 정하는 값은 설정
- 버전 이름 → 인자 (모델이 채움)
- 패키지 폴더 위치 → 서버 설정
- 경로를 인자로 받으면 모델이 아무 경로나 넣을 수 있다. 보안상 위험.
반드시 일어나야 하는 일은 모델이 아니라 코드에
- 테스트 후 정리를
cleanup도구로 두고 “보고서 쓴 뒤 불러”라고 하면 모델도 사람처럼 잊는다. - 실행 도구 안에서 “준비 → 실행 → 캡처 → 정리”를 끝까지 책임지게 한다. Swift로 치면
defer { cleanup() }. - 허락 없이 지우는 만큼 범위는 좁게: 실행 전용 폴더·복제 기기만 쓰고 통째로 지운다. 사용자의 기존 자산은 절대 건드리지 않는다.
- 중간에 죽을 때의 안전망: stdin
"close"때 정리 + 서버 시작 시 지난번 잔여물 청소.
지울지 남길지는 “똑같이 다시 만들 수 있나”
- 빌드 산출물, 시뮬레이터, 설치 앱 → 언제든 똑같이 다시 만들 수 있고 크다 → 지운다
- 모델이 판단해서 만든 서식·시나리오 → 다시 만들면 조금씩 달라지고 작다 → 남긴다 (재현, 수정 전후 비교에 필요)
서버는 하나일 필요가 없다
- 호스트에는 서버 여러 개를 붙일 수 있고, 모델은 여러 서버의 도구를 섞어 쓴다.
- 예: 로컬 서버(검색·Android·웹·Windows, 사용자 PC) + 원격 서버(iOS, Mac 한 대)
- 조합 폭발(플랫폼 × 버전 × 기기)에 대비해 실행 전 규모 안내, 결과는 차이 난 것 위주로.
MCP로 만드는 이유
- 핵심 장면: Windows에서 일하는 개발자가 “이 시나리오를 iOS랑 Windows에서 돌려 줘”라고 한다. 이걸 스킬로 하려면 무엇이 막히나?
결정적인 이유
- 실행할 기계가 요청한 사람의 기계가 아니다.
- 스킬은 요청한 사람의 PC에서 실행된다. Windows에서는 iOS 시뮬레이터를 띄울 수 없다.
- 원격 MCP 서버는 “AI 앱이 다른 기계의 도구를 부르는 표준 방법”이다. 플랫폼을 자유롭게 고르는 순간 요청 위치와 실행 위치가 분리된다.
- 공유 기계는 줄을 세워야 한다.
- Mac은 한 대인데 여러 명이 동시에 요청하면 시뮬레이터가 섞인다.
- 스킬은 사람마다 따로 돌아 서로를 모른다. 계속 켜져 있는 서버 하나가 순서대로 처리한다.
- 남의 기계에서는 허용된 동작만 해야 한다.
- 스킬로 하려면 원격 접속(SSH) 권한을 줘야 하고, 그러면 모델은 그 기계에서 아무 명령이나 실행할 수 있다.
- MCP 서버는 열어 둔 동작만 한다. 개발자별 토큰으로 누가 무엇을 돌렸는지도 남는다.
강한 이유
- 개발자마다 쓰는 AI 앱이 다르다. 스킬은 앱마다 형식과 실행 환경이 다르고, Desktop 스킬은 사내망에 닿지 못한다. MCP는 어느 앱에서 불러도 같다.
보통인 이유
- 정리와 복구를 확실히 보장한다. 스크립트도 자기 실행 안에서는
try/finally로 정리할 수 있다. 차이는 실행 바깥(죽은 실행의 흔적 청소, 보관함 관리)이다. - 입력 검증과 모델이 알아듣는 실패.
enum, 필수 칸 검사,isError로 다음 행동 안내. - 고치면 모두에게 바로 반영된다. 원격 서버는 한 번 고치면 끝. 스킬은 각자 갱신해야 한다.
반대로, MCP의 비용
- 운영 부담: Mac이 꺼지면 iOS 테스트가 전부 멈춘다. 누군가 관리해야 한다.
- 복잡도: 인증, 대기열, 시간 초과 처리는 스킬엔 없던 일.
- 개인용이면 과하다. 혼자 Mac에서 iOS만 돌린다면 스킬이 낫다.
한 문장으로
개발자들은 각자 다른 OS와 AI 앱을 쓰는데, 테스트는 플랫폼마다 특정 기계에서만 돌 수 있었다. 그래서 어디서 요청하든 맞는 기계에서 실행하고, 공유 기계는 줄을 세우고, 허용된 동작만 하게 하는 표준 접점이 필요했고, 그것을 원격·로컬 MCP 서버로 만들었다.