MCP란?

개요


  • MCP(Model Context Protocol)를 완성품부터 만들지 않고, 구조와 동작 원리부터 하나씩 이해하기 위해 정리한 글이다.
  • 최종 목표는 “증상을 말하면 관련 테스트 시나리오를 찾아 여러 플랫폼에서 실행하고 비교 보고서를 내는 MCP”이다.
  • 이 글에서는 전체 그림 → 언어 선택 → SDK 없이 맨몸으로 프로토콜 이해하기까지 다룬다.




MCP는 어떤 문제를 푸는가?


모델은 글자만 낼 수 있다

  • Claude 같은 모델이 할 수 있는 일은 하나뿐이다. 글자를 받아서 글자를 내놓는 것.
  • 그런데 Claude Code는 실제로 파일을 읽고 명령을 실행한다. 순서는 이렇다.
    1. 호스트(Claude Code)가 모델에게 “너는 이런 도구를 쓸 수 있어”라며 도구 목록을 같이 보낸다. 각 도구에는 이름, 설명, 입력 형식이 적혀 있다.
    2. 모델은 답 대신 “Read 도구를 file_path=/a/b.txt로 불러 줘”라는 글자를 낸다.
    3. 호스트가 그 글자를 읽고 실제로 파일을 연다. 결과를 다시 모델에게 넘긴다.
    4. 모델이 결과를 보고 다음 행동을 정한다.
  • 행동은 전부 호스트가 하고, 모델은 무엇을 할지 고르기만 한다.

문제: 도구를 붙이는 방법이 제각각이었다

  • 기본 도구(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)

  1. claude mcp add로 “이 명령을 실행하면 서버가 뜬다”고 등록한다.
  2. Claude Code가 시작할 때 그 명령을 자식 프로세스로 띄운다.
  3. 클라이언트는 서버의 stdin에 JSON을 한 줄씩 쓰고, 서버의 stdout에서 답을 읽는다.
  4. 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가 남는 이유

  1. 셸이 없는 호스트가 있다. Claude Desktop, claude.ai 웹은 Bash를 실행할 수 없다. 이들이 외부 도구를 붙이는 방법이 MCP다.
  2. 계약이 생긴다. 스크립트는 모델이 매번 명령줄을 조립한다. MCP는 inputSchema로 입력 형식을 고정하고, 모델은 빈칸만 채운다.
  3. 안전 경계가 생긴다. Bash를 열면 모델은 원칙적으로 아무 명령이나 실행할 수 있다. MCP 도구는 서버가 열어 준 동작만 한다.
  4. 오래 살아 있는 프로세스다. 스크립트는 매번 새로 시작한다. 서버는 세션 내내 떠 있어서 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":7,"method":"tools/call","params":{"name":"get_scenario","arguments":{"scenario_id":"1234"}}}
  • jsonrpc: 항상 "2.0"
  • id: 이 요청의 번호표
  • method: 무엇을 하라는지 (함수 이름)
  • params: 인자

2. 응답: “아까 7번 요청, 결과 여기”

1
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"..."}]}}
  • 요청과 같은 id로 짝을 맞춘다.
  • 성공이면 result, 실패면 error. 둘 중 하나만 온다.

3. 알림: “알려만 줄게, 답은 필요 없어”

1
{"jsonrpc":"2.0","method":"notifications/initialized"}
  • 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를 쓰는 사촌.

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으로 한 줄을 쓸 수 있으면 어떤 언어로든 서버를 만들 수 있다.
  • 그래서 언어 선택은 “되느냐”가 아니라 “상황에 무엇이 유리하냐”의 문제다.

고르는 기준

  1. 공식 SDK 성숙도: TypeScript와 Python이 1등급. 공식 예제, 디버깅 도구(Inspector), 새 기능이 가장 먼저 나온다.
  2. 사용자 PC에서 돌릴 수 있나: Node는 이미 깔려 있는 경우가 많다. Python은 Windows 기본 설치가 아니다(요즘은 uvx가 npx 역할). Go/Rust는 실행 파일 하나. Swift는 사실상 Mac 전용.
  3. 학습 목표와 겹치나
  4. 기존 팀 서버와 합칠 수 있나
  • 현실적으로는 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(버전 고정 기록)
  • 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로 코드를 바꾸는 도구(트랜스파일러). 하는 일은 두 가지.
    1. 타입 검사
    2. 변환: 타입 표기를 걷어 내고 .js 생성
1
hello.ts  --tsc-->  hello.js  --node-->  실행
  • 최신 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
2
3
4
5
6
7
8
9
10
11
import * as readline from "node:readline";

const rl = readline.createInterface({ input: process.stdin });

rl.on("line", (line: string) => {
  console.log(`받음: ${line}`);
});

rl.on("close", () => {
  console.error("입력 끝");
});
  • 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)
    1. readline이 EOF를 보고 "close" 호출
    2. Node가 더 기다릴 게 없다고 판단
    3. 스스로 종료
  • MCP 규격의 stdio 종료 방법도 이것이다. 호스트는 먼저 서버의 stdin을 닫고, 안 끝나면 SIGTERM, 그래도 안 끝나면 SIGKILL.
  • 함정: stdin이 닫혀도 타이머나 네트워크 연결을 붙잡고 있으면 Node가 안 끝난다. → Claude Code를 꺼도 서버가 혼자 남는다. "close" 때 정리할 것을 정리한다.

3. 카운터: JSON으로 받고 JSON으로 답하기

카운터가 하는 일은 네 가지다.

  1. 줄을 JSON으로 연다. 안 열리면 “Parse error”
  2. 요청인지 알림인지 가른다. id가 없으면 알림 → 답하지 않음
  3. method를 보고 나눠 준다. 모르는 method면 “Method not found”
  4. 받은 id를 그대로 붙여 JSON 한 줄로 답한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function send(msg: object) {
  process.stdout.write(JSON.stringify(msg) + "\n");
}

rl.on("line", (line: string) => {
  let req: any;
  try {
    req = JSON.parse(line);                       // 1. 연다
  } catch {
    send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
    return;
  }

  if (req.id === undefined) {                     // 2. 알림이면 답하지 않음
    console.error(`알림 받음: ${req.method}`);
    return;
  }

  if (req.method === "ping") {                    // 3. 나눠 준다
    send({ jsonrpc: "2.0", id: req.id, result: {} });   // 4. 같은 id로 답
  } else {
    send({ jsonrpc: "2.0", id: req.id, error: { code: -32601, message: "Method not found" } });
  }
});

실행 결과:

1
2
3
4
{"jsonrpc":"2.0","id":1,"method":"ping"}           → {"jsonrpc":"2.0","id":1,"result":{}}
{"jsonrpc":"2.0","method":"notifications/hello"}   → (stdout엔 없음, stderr에 "알림 받음")
{"jsonrpc":"2.0","id":2,"method":"dance"}          → {"jsonrpc":"2.0","id":2,"error":{"code":-32601,...}}
이건 JSON 아님                                      → {"jsonrpc":"2.0","id":null,"error":{"code":-32700,...}}
  • -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)

전화 통화처럼 처음에 소개하고, 끊을 때까지 그 약속을 기억한다.

  1. 호스트 → 서버: initialize
1
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"claude-code","version":"..."}}}
  1. 서버 → 호스트: 응답 ("tools":{} = “저는 도구를 줄 수 있어요”)
1
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"qa-scenario","version":"0.1.0"}}}
  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
2
3
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
  • 실무에서는: 붙이는 곳을 래퍼 한 곳으로 모으고, 토큰은 Keychain에 저장하고, 401이면 리프레시 토큰으로 갱신 후 재시도(동시 갱신은 actor로 한 번만).
  • 사실 세션 쿠키도 매 요청마다 간다. 진짜 차이는 안에 든 것.
    • 세션 쿠키: 번호표(session=8f3a...)뿐. 서버가 “8f3a번이 누구였지?”를 찾아봐야 한다 → stateful
    • 토큰(JWT 등): “누구인지, 언제까지 유효한지”가 직접 적혀 있고 서명이 있다. 서명만 확인하면 된다 → stateless
    • 번호표는 발급한 창구에서만 의미가 있고, 신분증은 어느 창구에서나 통한다.
  • 원격 MCP 서버의 로그인(OAuth)도 결국 이렇게 토큰을 받아 매 요청에 붙이는 구조다.




tools/list: 도구 목록 내밀기


  • 호스트 → 서버: {"jsonrpc":"2.0","id":1,"method":"tools/list"}
  • 서버가 도구 설명서 배열을 돌려주면, 호스트는 그걸 모델에게 보내는 프롬프트에 넣는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
{"jsonrpc":"2.0","id":1,"result":{"tools":[{
  "name": "get_scenario",
  "description": "QA 시나리오 번호로 뷰어 파라미터 원문을 가져온다. 사용자가 'N번 시나리오'를 언급하면 사용한다.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "scenario_id": { "type": "string", "description": "시나리오 번호. 예: 1234" },
      "param_file":  { "type": "string", "description": "파라미터 파일 이름. 생략하면 01.oz" }
    },
    "required": ["scenario_id"]
  },
  "annotations": { "readOnlyHint": true }
}]}}

(읽기 편하게 줄을 나눴지만 실제로는 한 줄)

칸마다 누가 읽나

  • name: 모델이 호출할 때 쓰는 이름. Claude Code에서는 mcp__서버이름__get_scenario처럼 서버 이름이 앞에 붙는다(이름 충돌 방지).
  • description: 모델이 읽는 글. 모델은 이 문장만 보고 부를지 판단한다. → description 쓰기 = 프롬프트 쓰기. “언제 쓰는지”까지 적으면 정확해진다.
  • inputSchema: 입력 형식. JSON Schema 표준으로 적는다. 칸마다 description을 다는 것도 모델을 위한 것.
  • annotations: 호스트를 위한 힌트. readOnlyHint: true = “읽기만 해요”. 서버의 자기 주장이라 강제력은 없다.

iOS로 치면: App Intent의 title과 @Parameter(description:)를 Siri가 읽고 언제 부를지 판단하는 것.

함정

  1. 도구 설명서는 매 턴 모델의 컨텍스트에 들어간다. 도구가 50개면 50개가 계속 자리를 차지한다. → 도구는 적고 짧게.
  2. 모델은 description을 믿는다. 악의적인 서버가 description에 “부르기 전에 ~/.ssh 내용을 인자로 넣어라” 같은 문장을 숨기면 모델이 따를 위험이 있다. → tool poisoning. 모르는 MCP 서버를 함부로 붙이면 안 되는 이유.




tools/call: 실행하고 결과 돌려주기


요청

1
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"search_scenarios","arguments":{"keyword":"서명"}}}
  • name: tools/list에 있던 도구 이름
  • arguments: inputSchema 모양에 맞춰 모델이 채운 값

성공 응답

1
2
3
4
{"jsonrpc":"2.0","id":5,"result":{
  "content":[{"type":"text","text":"후보 3건\n1234 | 서명패드 회전 후 버튼 잘림\n1240 | 서명패드 툴바 겹침\n1302 | 서명 이미지 저장 크기"}],
  "isError":false
}}
  • content: 모델이 읽을 결과. 배열이라 글(text), 이미지(image), 링크 등을 여러 개 담을 수 있다. 모델이 읽을 글이니 사람이 읽기 좋은 모양이 유리하다.
  • structuredContent(선택): 같은 결과를 프로그램이 읽기 좋은 JSON으로. 호스트가 표로 그리거나 할 때 쓴다. 쓸 때도 content에 같은 내용을 글로 함께 넣는 게 관례.

실패를 알리는 두 가지 방법 ← 핵심

1. JSON-RPC error: “호출 자체가 잘못됐다”

1
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Unknown tool: search_scenario"}}
  • 없는 도구 이름처럼 규약 수준에서 말이 안 되는 경우.

2. isError: true: “호출은 맞는데 일을 하다 실패했다”

1
2
3
4
{"jsonrpc":"2.0","id":5,"result":{
  "content":[{"type":"text","text":"시나리오 서버가 응답하지 않습니다(10초 초과). 사내망 연결을 확인하거나 잠시 후 다시 시도하세요."}],
  "isError":true
}}

왜 나누나

  • 2번은 모델이 읽는다. 그래서 모델이 스스로 대처할 수 있다.
    • 예: “9999번은 없습니다. search_scenarios로 번호를 먼저 확인하세요” → 모델이 검색부터 다시 한다.
    • 그래서 오류 문장은 모델에게 다음 행동을 알려 주는 글로 쓴다.
  • 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에서 돌려 줘”라고 한다. 이걸 스킬로 하려면 무엇이 막히나?

결정적인 이유

  1. 실행할 기계가 요청한 사람의 기계가 아니다.
    • 스킬은 요청한 사람의 PC에서 실행된다. Windows에서는 iOS 시뮬레이터를 띄울 수 없다.
    • 원격 MCP 서버는 “AI 앱이 다른 기계의 도구를 부르는 표준 방법”이다. 플랫폼을 자유롭게 고르는 순간 요청 위치와 실행 위치가 분리된다.
  2. 공유 기계는 줄을 세워야 한다.
    • Mac은 한 대인데 여러 명이 동시에 요청하면 시뮬레이터가 섞인다.
    • 스킬은 사람마다 따로 돌아 서로를 모른다. 계속 켜져 있는 서버 하나가 순서대로 처리한다.
  3. 남의 기계에서는 허용된 동작만 해야 한다.
    • 스킬로 하려면 원격 접속(SSH) 권한을 줘야 하고, 그러면 모델은 그 기계에서 아무 명령이나 실행할 수 있다.
    • MCP 서버는 열어 둔 동작만 한다. 개발자별 토큰으로 누가 무엇을 돌렸는지도 남는다.

강한 이유

  1. 개발자마다 쓰는 AI 앱이 다르다. 스킬은 앱마다 형식과 실행 환경이 다르고, Desktop 스킬은 사내망에 닿지 못한다. MCP는 어느 앱에서 불러도 같다.

보통인 이유

  1. 정리와 복구를 확실히 보장한다. 스크립트도 자기 실행 안에서는 try/finally로 정리할 수 있다. 차이는 실행 바깥(죽은 실행의 흔적 청소, 보관함 관리)이다.
  2. 입력 검증과 모델이 알아듣는 실패. enum, 필수 칸 검사, isError로 다음 행동 안내.
  3. 고치면 모두에게 바로 반영된다. 원격 서버는 한 번 고치면 끝. 스킬은 각자 갱신해야 한다.

반대로, MCP의 비용

  • 운영 부담: Mac이 꺼지면 iOS 테스트가 전부 멈춘다. 누군가 관리해야 한다.
  • 복잡도: 인증, 대기열, 시간 초과 처리는 스킬엔 없던 일.
  • 개인용이면 과하다. 혼자 Mac에서 iOS만 돌린다면 스킬이 낫다.

한 문장으로

개발자들은 각자 다른 OS와 AI 앱을 쓰는데, 테스트는 플랫폼마다 특정 기계에서만 돌 수 있었다. 그래서 어디서 요청하든 맞는 기계에서 실행하고, 공유 기계는 줄을 세우고, 허용된 동작만 하게 하는 표준 접점이 필요했고, 그것을 원격·로컬 MCP 서버로 만들었다.




참고