SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor

PlayMCP에 내 MCP 서버 등록하기: VPS 호스팅부터 심사까지

공식 MCP Python SDK 1.30.0으로 만든 읽기 전용 Streamable HTTP 서버를 내 도메인과 Caddy 뒤에 올리고, 토큰 헤더 검사와 curl 테스트를 거쳐 카카오 PlayMCP 도구함에 등록하는 과정을 단계별로 정리했습니다.

PlayMCP에 MCP 서버를 등록하려면 무엇이 필요한가

PlayMCP에 MCP 서버를 등록하려면 먼저 인터넷에서 HTTPS로 접근할 수 있는 원격 MCP(Model Context Protocol) 서버가 있어야 합니다. 이 글은 그 서버를 내 VPS와 내 도메인 위에 올리고, 카카오 PlayMCP 개발자 콘솔을 거쳐 도구함에 넣기까지의 과정을 다룹니다. 예시 서버는 공식 MCP Python SDK의 1.30.0 태그로 만든 작은 읽기 전용 도구 하나입니다. 앞단에는 Caddy를 두고, Caddy가 TLS(전송 계층 보안) 인증서와 토큰 헤더 검사를 맡습니다.

사용자 쪽에서 Claude에 PlayMCP를 연결하는 방법은 사용자용 PlayMCP 가이드에서 따로 다뤘습니다. 이 글은 그 반대편, 즉 도구를 만들어 올리는 개발자의 몫을 다룹니다.

이 글에 나오는 PlayMCP 제약은 어디서 왔나

PlayMCP의 공식 이용 가이드는 kko.kakao.com/playmcp_guide에 있고, 이 주소는 Notion 페이지로 연결됩니다. 이 글을 쓰는 동안 그 페이지는 자바스크립트로만 그려져서 본문을 직접 확인하지 못했습니다. 심사 정책(kko.kakao.com/playmcp_review)과 공지(kko.kakao.com/playmcp_notice)도 같은 이유로 읽지 못했습니다.

그래서 아래에 나오는 PlayMCP 쪽 제약은 모두 날짜가 붙은 개발자 후기에서 가져왔습니다. 하나는 2025년 12월 24일 velog에 올라온 LexLink MCP 개발기입니다. 다른 하나는 legal-navigator-mcp 저장소의 SUBMISSION.md입니다. 둘 다 카카오의 공식 문서가 아닙니다. 그래서 이 글은 이 내용들을 "PlayMCP의 규칙"이 아니라 "후기에 적힌 내용"으로 소개합니다. 등록하기 전에 브라우저로 이용 가이드를 직접 열어, 지금도 같은지 확인하세요.

사용자의 호출은 어떤 경로로 내 서버에 도착하나

사용자는 내 서버에 직접 연결하지 않습니다. 사용자는 PlayMCP의 도구함(Toolbox)을 AI 클라이언트에 연결하고, 그 도구함 안에 내 서버가 들어 있는 구조입니다.

  • Claude: Claude의 PlayMCP 커넥터는 커넥터 URL로 https://playmcp.kakao.com/mcp를 쓰고, 로그인이 필요합니다. 커넥터 페이지에는 "Added January 2026", "Made by KAKAO"로 표시되어 있습니다.
  • 카카오의 2025년 11월 24일 보도자료에 따르면, 도구함은 Claude의 설정 > Custom Connectors에서 연결합니다. ChatGPT에서는 Developer Mode에서 사용자 지정 MCP 서버 URL을 등록합니다. 연결하면 도구함의 모든 도구를 카카오 계정 인증 한 번으로 쓸 수 있습니다.
  • 카카오툴즈: 2026년 6월 17일 AGENTIC PLAYER 10 보도자료는 예선을 통과한 20개 서비스가 "카카오톡 내 카카오툴즈를 통해" 공개된다고 적고 있습니다.

PlayMCP가 직접 공개한 llms.txt는 MCP Gateway를 여러 MCP 서버를 하나의 OAuth2 엔드포인트로 묶어 주는 프록시로 설명합니다. 이 게이트웨이는 Bearer 액세스 토큰을 요구합니다. 같은 문서는 도구함을 사용자 한 명이 담을 수 있는 최대 10개 MCP 서버의 모음으로 정의합니다.

확인하지 못한 부분도 분명히 적어 둡니다. 게이트웨이에서 내 서버로 가는 마지막 구간이 어느 네트워크, 어떤 IP 주소에서 출발하는지는 제가 열어 본 1차 자료 어디에도 나오지 않습니다. 카카오툴즈가 Claude 커넥터와 같은 게이트웨이를 거치는지도 확인하지 못했습니다. 그래서 이 글은 IP 허용 목록으로 서버를 막지 않고, 토큰 헤더 하나로 접근을 제어합니다. 출발지 주소를 추측해서 막으면, 그 추측이 틀렸을 때 콘솔의 정보 불러오기가 아무 설명 없이 실패합니다.

개발자 후기에 적힌 PlayMCP 제약

LexLink 개발기(velog, 2025-12-24)에 적힌 내용입니다. 약 10개월 전 글이라는 점을 감안하세요.

  • 전송 방식은 "Streamable HTTP만 지원"합니다. stdio로만 동작하는 서버는 그대로 올릴 수 없다는 뜻입니다.
  • 인증은 "Key/Token 헤더 또는 OAuth"입니다. 헤더 이름과 값 형식은 후기에 나오지 않습니다.
  • "도메인 필수 (IP 불가)"입니다. 작성자는 고정 IP 등록으로도 해결할 수 없었다고 적었습니다.
  • 서버 호스팅과 그 비용은 개발자가 부담합니다. 작성자는 AWS 인스턴스에서 서버를 돌리고, ngrok 계정에 무료로 주어지는 도메인을 썼습니다.
  • 작성자는 도구 23개로 등록한 뒤 30개 가까이로 늘렸고, "등록 당시 설명과 달라지면 재심사를 받아야 한다는 것"이 불편했다고 적었습니다.

legal-navigator-mcp의 SUBMISSION.md에 적힌 내용입니다. 이 문서는 스스로를 예선 기록이라고 밝히므로, 지금 콘솔과는 다를 수 있습니다.

  • MCP 식별자는 "영문/숫자만, 'kakao' 금지"라고 적혀 있습니다. 문서의 예시 값은 legalNavi입니다.
  • 도구 메타데이터(이름, description, annotations)는 등록 시점에 캐시됩니다. 문서는 "KC 서버 '재배포'만으로는 콘솔에 반영되지 않는다"고 적고, 재배포한 뒤에는 엔드포인트의 정보 불러오기를 다시 실행하라고 안내합니다.

심사 기간은 이 글에서 약속하지 않습니다. 공모전 기간에 나온 처리 기한 안내는 그 기간에만 해당하는 약속이었습니다.

공모전은 배경으로만 알아 두기

카카오는 2026년 6월 17일 AGENTIC PLAYER 10을 발표했습니다. 접수는 7월 14일에 마감되었습니다. 본선 20개 서비스의 사용자 투표는 8월 31일부터 9월 28일까지 카카오톡의 카카오툴즈에서 진행되었고, 시상식은 10월 23일 카카오 AI 캠퍼스에서 열립니다. 참가하려면 PlayMCP에 서비스를 등록해야 했습니다. 그 이전에는 MCP Player 10이라는 별도의 공모전이 있었습니다(2025년 12월 19일 발표, 2026년 1월 18일 마감). 위의 개발자 후기들도 이런 공모전을 거치며 쓰였습니다. 두 공모전 모두 접수는 이미 끝났습니다.

어디에 호스팅해야 주소가 오래 유지되나

후기 속 개발자들이 실제로 쓴 방법은 크게 세 가지입니다. 이 셋의 차이는 성능이 아니라 영속성, 즉 그 주소가 얼마나 오래 살아 있느냐입니다. 도구함은 콘솔에 등록된 그 URL로 요청을 보내므로, 그 주소가 응답하지 않으면 도구도 함께 멈춥니다.

  • 카카오 클라우드의 PlayMCP in KC: legal-navigator-mcp 문서에 따르면 Git 소스(저장소와 Dockerfile)나 컨테이너 이미지를 등록하면 https://<이름>.playmcp-endpoint.kakaocloud.io/mcp 형태의 엔드포인트를 받습니다. 같은 문서는 이 서버가 "예선 기간(6/15~7/14)에만" 발급되었고 계정당 2대였다고 적습니다. 기간이 끝난 뒤 이 서버들이 어떻게 되는지 설명하는 카카오 문서는 찾지 못했습니다. 지금 새로 시작하는 사람에게는 선택지가 아닙니다.
  • ngrok 무료 도메인: ngrok 문서에 따르면 모든 계정에 무료 Dev Domain이 하나 자동으로 주어집니다. 이름은 고를 수 없고, 바꿀 수도 없습니다. 무료 계정용 기본 도메인은 ngrok-free.dev입니다. 문서는 이를 고정된 자동 할당 호스트 이름으로 설명하지만, 영구히 유지된다고 약속하지는 않습니다. 그리고 트래픽은 ngrok 에이전트를 돌리는 컴퓨터가 켜져 있을 때만 전달됩니다. LexLink 작성자는 이 도메인으로 등록에 성공했다고 적었지만, PlayMCP가 지금도 이 도메인을 받아 주는지는 확인하지 못했습니다.
  • 내 VPS와 내 도메인: 주소와 인증서와 서버 수명을 모두 내가 정합니다. 비용도 내가 냅니다. 이 글은 이 방법을 씁니다.

서버 준비: 도메인과 포트

필요한 것은 Ubuntu 24.04 VPS 하나와, 그 VPS의 공인 IP를 가리키는 서브도메인 하나입니다. 예시에서는 mcp.example.com을 씁니다. DNS에 A 레코드(IPv6를 쓰면 AAAA 레코드도)를 만들어 VPS 주소를 가리키게 하세요. 그리고 VPS 방화벽과 호스팅 업체의 네트워크 방화벽 양쪽에서 TCP 80과 443을 여세요. Caddy가 인증서를 받으려면 두 포트가 바깥에서 닿아야 합니다.

dig +short mcp.example.com

출력이 VPS의 공인 IP와 같아야 합니다. 다르면 레코드가 틀렸거나 아직 반영되지 않은 것입니다. VPS에서 MCP 서버를 운영할 때의 일반적인 구성과 선택지는 VPS에서 MCP 서버를 운영하는 방법에서 더 자세히 다룹니다.

MCP Python SDK 버전은 왜 1.30.0으로 고정하나

이 글은 mcp 패키지 1.30.0을 씁니다. 1.30.0은 2026년 9월 7일에 나온 v1 계열의 유지보수 릴리스입니다. 현재 최신 릴리스는 10월 2일에 나온 v2.3.0이고, v2는 API가 다릅니다. 예를 들어 v2 README는 FastMCP 대신 MCPServer를 씁니다. v2.0.0 릴리스 노트는 v1.x가 유지보수 모드라고 밝히고, v1 README는 v1에 머물려면 <2 상한을 두라고 안내합니다.

버전을 고정하지 않으면 다음 pip install에서 v2가 설치됩니다. 아래 코드는 v1 API로 쓰였으므로 v2에서 그대로 동작한다는 보장이 없습니다. 이 글에서 설명하는 기본값과 동작도 모두 1.30.0 소스 코드를 기준으로 합니다. 이 패키지는 Python 3.10 이상이 필요하므로 python3 --version으로 먼저 확인하세요.

sudo apt update
sudo apt install -y python3-venv tzdata
python3 --version
sudo mkdir -p /opt/dday-mcp
sudo python3 -m venv /opt/dday-mcp/venv
sudo /opt/dday-mcp/venv/bin/pip install "mcp==1.30.0"
/opt/dday-mcp/venv/bin/python -c "import importlib.metadata as m; print(m.version('mcp'))"

마지막 줄은 1.30.0을 출력해야 합니다. 다른 값이 나오면 버전 고정이 적용되지 않은 것입니다.

상태 없는 읽기 전용 도구 만들기

예시 도구는 D-day 계산기입니다. 날짜 하나를 받아서, 한국 시간 기준으로 오늘부터 그날까지 며칠 남았는지 돌려줍니다. 외부 API도, 데이터베이스도, 파일 쓰기도 없습니다. 심사하는 쪽도 쓰는 쪽도 이 도구가 하는 일을 코드 몇 줄로 확인할 수 있습니다.

sudo nano /opt/dday-mcp/server.py로 파일을 열고 아래 내용을 넣으세요. DOMAIN 값은 여러분의 도메인으로 바꿉니다.

from datetime import date, datetime
from zoneinfo import ZoneInfo

from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations

DOMAIN = "mcp.example.com"

mcp = FastMCP(
    "dday",
    host="127.0.0.1",
    port=8000,
    stateless_http=True,
    json_response=True,
    transport_security=TransportSecuritySettings(
        enable_dns_rebinding_protection=True,
        allowed_hosts=[DOMAIN, "127.0.0.1:*", "localhost:*"],
        allowed_origins=[f"https://{DOMAIN}"],
    ),
)


@mcp.tool(
    annotations=ToolAnnotations(
        title="D-day 계산",
        readOnlyHint=True,
        destructiveHint=False,
        openWorldHint=False,
    )
)
def dday(target: str) -> dict:
    """target 날짜(YYYY-MM-DD)까지 한국 시간 기준으로 며칠 남았는지 계산합니다. 외부 시스템을 읽거나 바꾸지 않습니다."""
    today = datetime.now(ZoneInfo("Asia/Seoul")).date()
    goal = date.fromisoformat(target)
    return {
        "target": goal.isoformat(),
        "today_kst": today.isoformat(),
        "days_left": (goal - today).days,
    }


if __name__ == "__main__":
    mcp.run(transport="streamable-http")

설정마다 이유가 있습니다.

  • host="127.0.0.1", port=8000: 1.30.0의 기본값과 같지만 일부러 적었습니다. 서버는 루프백 주소에서만 요청을 받으므로, 바깥에서 오는 요청은 반드시 Caddy를 거칩니다. 토큰 검사가 Caddy에 있으므로 이 점이 중요합니다.
  • stateless_http=True, json_response=True: 1.30.0의 기본값은 둘 다 False입니다. SDK 예제는 이 조합에 "recommended"라는 주석을 달아 두었습니다. 상태 없는(stateless) 모드에서 서버는 세션을 저장하지 않으므로, 모든 요청을 독립적으로 처리합니다. 게이트웨이가 요청을 어떤 순서로 보내든, 서버 쪽에는 잃어버릴 상태가 없습니다. 이 개념은 상태 없는 MCP가 무엇이고 언제 쓰는지에서 자세히 설명합니다.
  • transport_security: 이 설정을 빼면 공인 도메인으로 들어온 요청이 거부됩니다. 1.30.0 소스를 보면, transport_security를 주지 않고 host가 127.0.0.1, localhost, ::1 중 하나이면 FastMCP가 DNS 리바인딩 보호를 스스로 켭니다. 이때 허용 Host는 127.0.0.1:*, localhost:*, [::1]:*뿐입니다. 그런데 Caddy는 기본적으로 들어온 Host 헤더를 바꾸지 않고 백엔드에 넘깁니다. 그래서 바깥 요청의 Host는 mcp.example.com이고, 이 값이 허용 목록에 없으므로 거부됩니다. 소스 코드상 이때 응답은 상태 421과 본문 Invalid Host header입니다.

allowed_hosts의 항목은 정확히 일치해야 통과합니다. 항목이 :*로 끝나면 포트 번호만 아무 값이나 허용합니다. HTTPS 기본 포트로 들어온 요청의 Host에는 포트가 붙지 않으므로, 도메인을 포트 없이 그대로 적습니다. Origin 헤더가 없는 요청은 Origin 검사를 통과합니다. Origin이 있는데 허용 목록에 없으면 403과 Invalid Origin header가 돌아옵니다.

도구의 docstring은 그대로 도구의 description이 됩니다. 이 문장이 모델에게 보이는 설명이고, 후기에 따르면 등록 시점에 캐시되는 메타데이터이기도 합니다. 그래서 처음부터 정확하게 쓰는 편이 좋습니다.

systemd 서비스로 띄우기

/etc/systemd/system/dday-mcp.service 파일을 만듭니다.

[Unit]
Description=D-day MCP server
After=network.target

[Service]
ExecStart=/opt/dday-mcp/venv/bin/python /opt/dday-mcp/server.py
DynamicUser=yes
Restart=on-failure

[Install]
WantedBy=multi-user.target

DynamicUser=yes는 서비스가 시작될 때마다 임시 사용자를 만들고, 그 권한으로 프로세스를 실행합니다. 이 서버는 디스크에 아무것도 쓰지 않으므로 root 권한이 필요 없습니다.

sudo systemctl daemon-reload
sudo systemctl enable --now dday-mcp
systemctl status dday-mcp --no-pager
sudo ss -ltnp | grep 8000

ss 출력에 127.0.0.1:8000이 보여야 합니다. 0.0.0.0:8000이 보이면 서버가 모든 인터페이스에 열린 것이므로 다음 단계로 넘어가지 마세요. 서비스가 바로 멈추면 journalctl -u dday-mcp -n 50에서 Python 오류를 읽으세요.

Caddy는 Ubuntu 아카이브와 공식 저장소 중 어디서 설치하나

Ubuntu 아카이브에도 caddy 패키지가 있습니다. universe 컴포넌트에 들어 있고, 24.04(noble)의 버전은 2.6.2-6ubuntu0.24.04.3입니다. 26.04(resolute)도 2.6.2 계열입니다. 반면 Caddy의 GitHub 최신 릴리스는 10월 3일에 나온 v2.11.7입니다. 이 글의 Caddy 설정은 현재 Caddy 문서를 보고 썼습니다. 그래서 문서와 같은 버전을 쓰도록, Caddy가 직접 운영하는 apt 저장소에서 설치합니다. 아래 명령은 Caddy 설치 문서의 Debian/Ubuntu 안정 버전 블록 그대로입니다.

sudo apt install --yes debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

Caddy 문서에 따르면 이 패키지는 caddy라는 systemd 서비스로 자동 시작됩니다.

caddy version
systemctl status caddy --no-pager

caddy version이 2.6.2가 아니라 Caddy 저장소의 버전을 출력해야 합니다. 2.6.2가 보이면 Ubuntu 아카이브 패키지가 설치된 것입니다. Caddy의 일반적인 사용법은 VPS에서 Caddy로 리버스 프록시를 설정하는 방법에서 다룹니다.

Caddyfile로 HTTPS와 토큰 헤더 검사 설정하기

먼저 토큰을 하나 만듭니다. 16진수 문자만 쓰면 Caddyfile 안에서 따옴표나 특수 문자 때문에 생기는 문제가 없습니다.

openssl rand -hex 32

/etc/caddy/Caddyfile의 내용을 아래로 바꾸고, REPLACE_WITH_TOKEN 자리에 방금 만든 값을 넣으세요.

mcp.example.com {
    log

    @notoken {
        not header X-MCP-Token "REPLACE_WITH_TOKEN"
    }
    respond @notoken 401

    reverse_proxy 127.0.0.1:8000
}

header 매처는 지정한 헤더 필드와 값이 맞는 요청에 일치하고, not은 그 결과를 뒤집습니다. 그래서 @notoken은 토큰 헤더가 없거나 값이 다른 모든 요청에 일치합니다. respond는 그 요청에 401을 돌려주고, 요청은 Python 서버까지 가지 않습니다. 토큰이 맞는 요청만 reverse_proxy로 넘어갑니다. log 한 줄은 접근 로그를 켭니다. 나중에 콘솔의 정보 불러오기가 실패할 때, 요청이 도착했는지와 어떤 상태 코드를 받았는지 이 로그에서 볼 수 있습니다. Caddy 문서에 따르면 text/event-stream 응답은 항상 즉시 흘려보내므로(flush), 나중에 스트리밍 응답을 켜도 이 설정을 그대로 쓸 수 있습니다.

헤더 이름 X-MCP-Token은 이 글에서 정한 예시일 뿐입니다. PlayMCP 콘솔의 Key/Token 인증이 어떤 헤더 이름과 값 형식을 쓰는지, 개발자가 이름을 정할 수 있는지는 확인하지 못했습니다. 콘솔의 인증 입력란을 먼저 보세요. 만약 Authorization: Bearer ... 형식만 받는다면, 매처를 not header Authorization "Bearer REPLACE_WITH_TOKEN"으로 바꾸면 됩니다.

Caddyfile에는 토큰이 평문으로 들어 있습니다. 이 파일을 공개 저장소에 올리지 마세요.

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
journalctl -u caddy -n 50 --no-pager

validate가 오류 없이 끝난 뒤에만 다시 읽기(reload)를 하세요. Caddy는 설정에 도메인 이름이 있으면 자동 HTTPS를 켭니다. 현재 Caddy 문서 기준으로, 기본 ACME(자동 인증서 관리 환경) CA는 Let's Encrypt와 ZeroSSL 두 곳입니다. Let's Encrypt에서 발급이 실패하면 ZeroSSL을 시도합니다. 실제로 어느 쪽 인증서를 받았는지는 다음 명령의 issuer 줄로 확인합니다.

curl -sv -o /dev/null https://mcp.example.com/ 2>&1 | grep -i issuer

이 요청에는 토큰이 없으므로 응답은 401이지만, TLS 연결 정보는 그대로 출력됩니다. Caddy 문서는 자동 HTTPS의 조건으로 80과 443 포트가 바깥에서 닿을 것, 그리고 DNS가 이 서버를 가리킬 것을 듭니다. 발급이 실패하면 이 두 가지부터 확인하세요.

curl로 initialize와 tools/list 확인하기

MCP 사양(2025-11-25 개정판)의 Streamable HTTP 전송에서는, 클라이언트의 모든 JSON-RPC 메시지가 하나의 엔드포인트로 가는 새 HTTP POST입니다. 클라이언트는 Accept 헤더에 application/json과 text/event-stream을 모두 적어야 합니다. 초기화 뒤의 요청에는 MCP-Protocol-Version 헤더를 붙여야 하고, 서버는 잘못되었거나 지원하지 않는 버전에 400으로 답해야 합니다. 아래 curl 명령은 이 규칙을 따릅니다. 1.30.0의 기본 엔드포인트 경로는 /mcp입니다.

먼저 VPS 안에서 Caddy를 거치지 않고 앱만 확인합니다. 문제가 생겼을 때 앱 문제와 프록시 문제를 나눠 보기 위해서입니다.

curl -sS -i http://127.0.0.1:8000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}'

응답 본문에 error가 아니라 result가 있으면 앱은 정상입니다. 응답의 result.protocolVersion 값을 적어 두세요. 사양상 서버는 요청한 버전을 지원하지 않으면 자기가 지원하는 다른 버전으로 답할 수 있습니다. 그래서 이 글에 적힌 값이 아니라, 여러분의 서버가 실제로 돌려준 값을 다음 요청에 씁니다.

이제 노트북 같은 바깥 컴퓨터에서, 토큰을 붙여 Caddy를 거친 경로를 확인합니다.

TOKEN='REPLACE_WITH_TOKEN'
VER='REPLACE_WITH_PROTOCOL_VERSION_FROM_INITIALIZE'

curl -sS -i https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "X-MCP-Token: $TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0.1"}}}'

curl -sS -i https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "X-MCP-Token: $TOKEN" \
  -H "MCP-Protocol-Version: $VER" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

tools/list 응답에서 dday 도구의 description과 annotations가 위 코드와 같은지 확인하세요. 후기에 따르면 이것이 등록 시점에 PlayMCP에 캐시되는 메타데이터입니다.

마지막으로 토큰 없이, 그리고 틀린 토큰으로 같은 요청을 보냅니다.

curl -sS -i https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

curl -sS -i https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'X-MCP-Token: wrong' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

두 요청 모두 상태 줄이 401이어야 합니다. 이 401은 Caddyfile의 respond 줄이 만든 것입니다. 여기서 도구 목록이 돌아온다면 매처가 동작하지 않는 것이고, 토큰 없이 누구나 도구를 부를 수 있다는 뜻입니다. 이 상태로는 콘솔 등록으로 넘어가지 마세요.

PlayMCP 개발자 콘솔에서 서버를 등록하는 순서

콘솔 주소는 playmcp.kakao.com/console이고 로그인이 필요합니다. PlayMCP의 llms.txt는 이곳에서 MCP 서버를 등록하고 수정하며 심사 상태를 확인한다고 설명합니다. 아래 순서는 legal-navigator-mcp SUBMISSION.md의 기록(2026년 6월부터 8월 사이)을 따른 것입니다. 지금 화면의 버튼 이름이 다르면 화면을 따르세요.

  1. 개발자 콘솔에서 "새로운 MCP 서버 등록"을 누릅니다.
  2. MCP 식별자를 입력합니다. 후기에는 영문과 숫자만 쓸 수 있고 kakao라는 문자열은 금지라고 적혀 있습니다. 예를 들어 ddayKst처럼 정합니다.
  3. 엔드포인트 URL에 https://mcp.example.com/mcp를 넣습니다. 인증 입력란이 있으면 위에서 정한 헤더와 토큰을 입력합니다. 그다음 "정보 불러오기"를 눌러 도구 목록을 성공적으로 불러오는지 확인합니다.
  4. "임시 등록"을 합니다.
  5. "도구함에 추가"를 누르고, PlayMCP AI채팅에서 직접 도구를 불러 봅니다. 예를 들어 "2026-12-25까지 며칠 남았어?"라고 물어서 dday 도구가 호출되는지 봅니다.
  6. 문제가 없으면 "등록 및 심사 요청"을 누릅니다.
  7. 승인 메일을 받은 뒤 공개 상태를 "전체 공개"로 바꿉니다.

3단계의 정보 불러오기가 실패하면, 먼저 바깥 컴퓨터에서 위의 curl 명령을 다시 돌려 보세요. 바깥에서는 되는데 콘솔에서만 실패한다면 journalctl -u caddy -n 50에서 접근 로그를 봅니다. 그 시각에 요청 기록이 없으면 요청이 서버에 오지 않은 것입니다. 기록이 있는데 상태가 401이면, 콘솔이 보내는 헤더 이름이나 값 형식이 Caddyfile의 매처와 다른 것입니다.

서버를 고쳐 재배포하면 PlayMCP에 언제 반영되나

SUBMISSION.md에 따르면 도구 메타데이터는 등록 시점에 캐시되므로, 서버를 다시 배포하는 것만으로는 콘솔에 반영되지 않습니다. 그래서 재배포는 다음 순서로 합니다.

  1. server.py를 고친 뒤 sudo systemctl restart dday-mcp로 다시 시작합니다.
  2. 바깥에서 tools/list curl을 다시 보내, 새 메타데이터가 나오는지 확인합니다.
  3. 콘솔에서 엔드포인트의 "정보 불러오기"를 다시 실행합니다.
  4. "등록 및 심사 요청"을 다시 보냅니다.

LexLink 작성자는 등록 당시 설명과 달라지면 재심사를 받아야 했다고 적었습니다. 정확히 무엇이 재심사를 일으키는지는 그 글에도 정의되어 있지 않습니다. 그러니 함수 안쪽의 버그 수정과 description 변경은 따로 생각하세요. description은 꼭 필요할 때만 바꾸고, 바꿀 때는 재심사를 거친다고 가정하는 편이 안전합니다. 심사가 얼마나 걸리는지는 이 글에서 약속하지 않습니다.

도구 설명과 annotations는 왜 안전 장치가 아닌가

사용자용 가이드에서는 독자에게, PlayMCP 도구함에 넣는 도구의 설명을 그대로 믿지 말라고 경고했습니다. 개발자 쪽에서 보면 그 설명과 annotations가 바로 내가 책임지는 부분입니다.

MCP 사양(2025-11-25)의 ToolAnnotations 기본값은 다음과 같습니다.

  • readOnlyHint: 기본값 false입니다. true는 도구가 환경을 바꾸지 않는다는 뜻입니다.
  • destructiveHint: 기본값 true입니다. readOnlyHint가 false일 때만 의미가 있습니다.
  • idempotentHint: 기본값 false입니다.
  • openWorldHint: 기본값 true입니다.

즉, annotations를 아예 쓰지 않은 도구는 사양의 기본값으로 읽으면 "환경을 바꿀 수 있고, 파괴적일 수 있으며, 외부 세계와 상호작용하는" 도구입니다. 이 글의 코드가 네 값 중 세 개를 직접 적은 이유입니다. 이 도구는 실제로 아무것도 바꾸지 않고, 외부와 통신하지도 않습니다.

하지만 사양은 이 값들이 모두 힌트(hint)일 뿐이고 도구의 실제 동작을 충실히 설명한다는 보장이 없다고 적습니다. 또 클라이언트는 신뢰할 수 없는 서버에서 온 annotations를 근거로 도구 사용을 결정해서는 안 된다고 적습니다. 도구의 description도 사양에서는 모델에게 주는 힌트로 설명됩니다. 악의적인 서버는 쓰기 도구에 readOnlyHint=True를 붙일 수 있고, description에 모델을 조종하는 문장을 넣을 수도 있습니다. 이런 공격이 어떻게 동작하는지는 코딩 에이전트를 노리는 프롬프트 인젝션에서 다룹니다.

그래서 개발자가 지킬 일은 단순합니다. annotations는 실제 동작과 정확히 맞게 적습니다. description에는 도구가 하는 일과 입력 형식만 쓰고, "항상 이 도구를 먼저 호출하라" 같은 모델 지시는 넣지 않습니다. 도구의 반환값도 모델의 입력이 되므로, 바깥에서 가져온 문자열을 가공 없이 돌려주는 도구라면 그 문자열이 어떤 지시를 담을 수 있는지 생각해야 합니다. 이 글의 도구는 계산한 날짜와 숫자만 돌려줍니다. PlayMCP 심사자나 카카오툴즈가 annotations를 읽고 무언가를 결정하는지는 확인하지 못했습니다.

오류 코드별로 무엇을 확인해야 하나

아래 응답 본문은 SDK 1.30.0 소스 코드와 이 글의 Caddyfile에서 나온 것입니다.

  • 421, Invalid Host header: 요청의 Host가 allowed_hosts에 없습니다. DOMAIN 값이 실제 도메인과 같은지 확인하세요. transport_security 설정을 통째로 뺐을 때도 이 응답이 납니다.
  • 400, Invalid Content-Type header: POST 요청의 Content-Type이 없거나 application/json으로 시작하지 않습니다. SDK는 DNS 리바인딩 보호를 꺼도 이 검사는 항상 합니다. curl에서 -H 'Content-Type: application/json'을 빠뜨렸는지 보세요.
  • 403, Invalid Origin header: 요청에 Origin 헤더가 있는데 허용 목록에 없습니다. 브라우저 기반 테스트 도구는 Origin을 붙여 보내므로, 그런 도구로 시험할 때 이 응답을 볼 수 있습니다.
  • 본문 없는 401: Caddy의 토큰 매처가 요청을 막았습니다. 헤더 이름과 값을 Caddyfile과 한 글자씩 비교하세요.
  • 연결 자체가 실패함: systemctl status dday-mcp로 서버가 살아 있는지, sudo ss -ltnp | grep 8000으로 포트가 열렸는지 확인하세요. Caddy는 백엔드에 닿지 못하면 요청을 전달할 곳이 없습니다.

FAQ

PlayMCP에 IP 주소로 MCP 서버를 등록할 수 있나요?

2025년 12월 24일 velog LexLink 개발기는 "도메인 필수 (IP 불가)"라고 적었고, 작성자는 고정 IP 등록으로도 해결하지 못했다고 밝혔습니다. 이것은 개발자 후기이고 PlayMCP 이용 가이드로 확인한 규칙은 아니지만, 처음부터 도메인을 준비하는 편이 안전합니다. 서브도메인 하나를 VPS의 공인 IP로 연결하고, Caddy가 그 도메인으로 HTTPS 인증서를 받게 하면 됩니다.

서버 코드를 고쳤는데 PlayMCP 도구함에 반영되지 않습니다. 왜 그런가요?

legal-navigator-mcp의 SUBMISSION.md에 따르면 도구 메타데이터(이름, description, annotations)는 등록 시점에 캐시되므로, 재배포만으로는 콘솔에 반영되지 않습니다. 개발자 콘솔에서 엔드포인트의 "정보 불러오기"를 다시 실행하고 "등록 및 심사 요청"을 다시 보내세요. 한 개발자는 description이 등록 당시와 달라지면 재심사를 받아야 했다고 적었습니다.

stdio로 만든 MCP 서버를 PlayMCP에 올릴 수 있나요?

개발자 후기에 따르면 PlayMCP는 "Streamable HTTP만 지원"하므로 stdio 서버는 그대로 올릴 수 없습니다. MCP Python SDK 1.30.0의 FastMCP라면 mcp.run(transport="streamable-http")로 같은 도구를 HTTP 엔드포인트로 띄울 수 있고, 기본 경로는 /mcp입니다. 그다음 HTTPS 리버스 프록시와 인증을 앞에 두면 됩니다.

공인 도메인으로 요청하면 421 Invalid Host header가 납니다. 무엇이 문제인가요?

MCP Python SDK 1.30.0은 transport_security를 따로 주지 않고 127.0.0.1에 바인딩하면 DNS 리바인딩 보호를 켜고, 127.0.0.1, localhost, [::1] Host만 허용합니다. Caddy는 원래의 Host 헤더를 그대로 넘기므로, 도메인 Host가 거부되어 421이 납니다. TransportSecuritySettings(allowed_hosts=["mcp.example.com", ...])처럼 도메인을 허용 목록에 넣고 서비스를 다시 시작하세요.