왜 만들었나

러닝을 하다 보면 "이번 달에 마감 안 지난 대회가 뭐가 있지?"를 생각보다 자주 검색하게 된다. 그때마다 포털에 "2026 마라톤 일정"을 치고 블로그 글들을 뒤지는 게 번거로웠다. AI를 실무에 붙이는 공부를 하던 참이라, 이 검색을 아예 AI 에이전트가 대신 해주는 도구로 만들어 보기로 했다. 그렇게 나온 게 run-or-die-server라는 작은 MCP 서버다.

단순히 만들어서 혼자 쓰는 걸로 끝내지 않고, 카카오가 운영하는 MCP 플랫폼인 PlayMCP에 정식으로 등록해서 심사까지 받아봤다. 이 글은 그 전체 과정 — MCP 개념부터, 코드로 구현하고, 로컬에서 검증하고, 배포하고, 심사받고, 반려당해서 고친 것까지 — 를 기록으로 남긴 것이다.

MCP가 뭔가

**MCP(Model Context Protocol)**는 Anthropic이 공개한, AI 모델이 외부 도구나 데이터에 접근하는 방식을 표준화한 프로토콜이다. 비유하자면 USB가 생기기 전엔 기기마다 전용 케이블이 필요했던 것처럼, MCP가 없을 때는 AI 에이전트마다 “이 서비스는 이렇게, 저 서비스는 저렇게” 각자 다른 방식으로 연동 코드를 짜야 했다. MCP는 이 연결 규격을 하나로 통일한다.

구조는 크게 세 쪽으로 나뉜다.

  • 호스트(Host): 사용자가 실제로 쓰는 AI 애플리케이션(예: Claude 데스크톱 앱, 카카오톡의 AI 기능).
  • 클라이언트(Client): 호스트 안에서 MCP 서버 하나하나와 통신을 맡는 연결 담당.
  • 서버(Server): 실제 기능을 제공하는 쪽. 내가 만든 run-or-die-server가 여기에 해당한다.

서버는 자신이 어떤 **툴(tool)**을 제공하는지 tools/list로 알리고, 모델이 필요하다고 판단하면 tools/call로 그 툴을 호출한다. 이번에 내가 만든 서버는 GetRunnersHigh라는 툴 하나를 제공한다.

카카오의 PlayMCP는 이 MCP 생태계 위에 올라간 국내 플랫폼이다. 카카오맵, 다음 검색처럼 실시간 국내 데이터를 AI가 쓸 수 있게 열어주는 동시에, 개발자가 직접 만든 MCP 서버도 등록해서 심사를 거쳐 공개할 수 있게 해준다. ChatGPT, Claude, Cursor 같은 여러 클라이언트에 동시에 연결할 수 있다는 것도 특징이다.

뭘 만들었나 — run-or-die-server

GetRunnersHigh 툴 하나짜리 서버다. 지역과 연도를 받아서 네이버 검색 API로 그 조건에 맞는 마라톤 대회 접수 일정을 찾아온다.

1
2
3
4
5
6
7
inputSchema: {
type: "object",
properties: {
region: { type: "string", description: "조회할 특정 지역 (예: 서울, 경기, 전국)" },
targetYear: { type: "string", description: "조회할 연도 (YYYY 형식). 미입력 시 올해 연도로 자동 지정됩니다." }
}
}

region, targetYear 둘 다 선택값이고, 비워두면 "전국"과 "올해"로 자동 채워진다. 매년 숫자를 하드코딩하고 싶지 않아서, 연도는 항상 서버가 요청을 받는 시점의 날짜로 동적으로 계산하게 했다.

1
2
3
4
5
6
7
const getDynamicDateInfo = () => {
const now = new Date();
return {
currentYear: now.getFullYear(),
currentDate: now.toISOString().split("T")[0]
};
};

구현 — SDK로 서버 띄우기

@modelcontextprotocol/sdk의 저수준 Server 클래스를 그대로 썼다. ListToolsRequestSchema 핸들러에서 위의 툴 정의를 반환하고, CallToolRequestSchema 핸들러에서 실제로 네이버 API를 호출해 결과를 텍스트로 정리해 돌려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== "GetRunnersHigh") {
throw new Error("Tool not found");
}

const args = (request.params.arguments ?? {}) as { region?: string; targetYear?: string };
const { currentYear, currentDate } = getDynamicDateInfo();

const searchYear = args.targetYear || String(currentYear);
const searchRegion = args.region || "전국";
const searchQuery = `${searchYear}년 ${searchRegion} 마라톤 대회 일정 접수일자 모집`;

// ... 네이버 검색 API(webkr.json) 호출, 결과를 정리해서 content로 반환
});

흥미로운 점은, 서버 인스턴스를 전역에 하나만 만들지 않고 요청이 들어올 때마다 createMcpServer()로 새로 만든다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
app.post("/mcp", async (req, res) => {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

res.on("close", async () => {
await transport.close();
await server.close();
});

await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});

MCP 스펙 자체가 "프로토콜 레벨의 세션 개념이 없다"고 명시하고 있다. 즉 서버가 여러 요청에 걸쳐 암묵적으로 상태를 들고 있을 거라고 기대하면 안 된다는 뜻이다. GetRunnersHigh는 어차피 상태가 필요 없는(stateless) 단순 조회 툴이라서, 요청마다 서버와 트랜스포트를 새로 만들고 응답이 끝나면 바로 정리하는 쪽이 더 안전하고 단순했다.

처음엔 SSE로 시작했다가, 결국 다 갈아엎었다

맨 처음 버전은 지금과 꽤 달랐다. 당시 코드 주석에 "카카오 플랫폼 연결용 외부 노출 SSE 인프라 설정 (카카오 필수 사양)"이라고 적어뒀을 만큼, SSEServerTransport 기반 /sse, /messages 2개 엔드포인트 구조로 짰다.

그런데 바로 버그를 만났다.

1
2
3
메시지 처리 실패
- 존재하지 않는 handleMessage() 호출
- handlePostMessage(req, res)로 변경

SDK 문서만 보고 transport.handleMessage(req.body)를 호출하게 짰는데, 실제 SSEServerTransport에는 그런 메서드가 없었다. Express의 req/res를 그대로 넘기는 handlePostMessage(req, res)가 맞는 API였다. 타입 체크만으로는 안 걸러지고 런타임에 터지는 종류의 실수라, 로컬에서 직접 호출해보지 않았으면 그대로 배포했을 뻔했다.

이후 MCP 쪽에서 SSE 트랜스포트 대신 Streamable HTTP가 표준으로 자리잡으면서, 통째로 리팩터링했다. SSEServerTransport를 걷어내고 StreamableHTTPServerTransport로 바꾸고, 로컬 테스트용으로 StdioServerTransport도 같이 지원하도록 --stdio 플래그 분기를 추가했다. 전역 server 하나를 만들어 쓰던 구조도 지금의 createMcpServer() 팩토리 함수 패턴으로 바뀐 게 이 시점이다.

1
2
3
4
5
6
7
if (process.argv.includes("--stdio")) {
const server = createMcpServer();
const transport = new StdioServerTransport();
await server.connect(transport);
} else {
// ... Express로 HTTP 서버 띄우고 /mcp 에서 StreamableHTTPServerTransport 사용
}

같은 createMcpServer()를 로컬(stdio)과 배포(HTTP) 양쪽에서 그대로 재사용하니, 로컬에서 검증한 툴 정의와 배포된 서버가 실제로 내려주는 툴 정의가 어긋날 걱정이 없어졌다.

로컬에서 먼저 확인하기

배포 전에 --stdio 플래그로 로컬에서 먼저 띄워서 확인했다. Claude 데스크톱 앱의 설정 파일에 로컬 실행 명령을 등록해두면, 앱을 열 때마다 이 서버가 떠서 바로 테스트해볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
{
"mcpServers": {
"run-or-die-server": {
"command": "node",
"args": ["dist/index.js", "--stdio"],
"env": {
"NAVER_CLIENT_ID": "...",
"NAVER_CLIENT_SECRET": "..."
}
}
}
}

이렇게 등록해두면 Claude와 대화하면서 "서울에서 이번 가을에 하는 마라톤 있어?"처럼 물어봤을 때 모델이 GetRunnersHigh를 직접 호출하는 걸 눈으로 확인할 수 있다. 배포 전에 입력 스키마가 모델 입장에서 헷갈리지 않는지, 응답 텍스트가 실제로 쓸만한지를 여기서 먼저 걸러냈다.

배포 — 클라우드타입 + 가비아

로컬 검증이 끝난 뒤에는 클라우드타입에 Node.js 앱으로 올렸다. 빌드는 npm run build(tsc로 src/를 dist/로 컴파일), 실행은 npm run start(node dist/index.js)이고, NAVER_CLIENT_ID/NAVER_CLIENT_SECRET을 환경변수로 등록해줬다. 도메인은 가비아에서 구입한 뒤 클라우드타입에 연결해서, 최종적으로 https://api-runnershigh.com/mcp라는 주소로 외부에 노출했다.

PlayMCP에 등록하고, 로컬 테스트 심사부터

개발자 콘솔에 MCP 서버를 등록하면 먼저 로컬 테스트 심사 단계를 거친다. 여기서는 등록한 엔드포인트가 MCP 프로토콜대로 제대로 응답하는지(초기화, tools/list, 실제 tools/call까지) 확인한다. 이 단계는 무사히 통과했고, 그다음 최종 심사를 신청했다.

최종 심사에서 반려당하다

며칠 뒤 반려 메일이 왔다.

[GetRunnersHigh] : 툴 annotations가 정의되지 않았습니다.
[GetRunnersHigh] : description에 서비스명(run-or-die-server)을 포함해야 합니다.

원인 분석

annotations는 MCP 스펙에서 툴의 "성격"을 클라이언트와 모델에게 미리 알려주는 메타데이터다. 현재 스펙 기준으로 다음 네 가지 힌트가 표준으로 자리잡고 있다.

필드 의미
readOnlyHint 이 툴이 환경을 바꾸지 않고 조회만 하는지
destructiveHint 실행했을 때 파괴적인 변경을 일으킬 수 있는지
idempotentHint 같은 인자로 여러 번 호출해도 추가 효과가 없는지
openWorldHint 서버 바깥의 열린 세계(외부 API 등)와 상호작용하는지

내가 만든 GetRunnersHigh는 네이버 API를 조회만 할 뿐 아무것도 바꾸지 않는 툴인데, 애초에 이 필드 자체를 아예 안 채워뒀었다. 모델이나 사람이 "이 도구를 호출해도 안전한가"를 판단할 때 쓰는 정보라, 비어 있으면 플랫폼 입장에서는 안전성을 확인할 수 없는 셈이니 반려가 합리적이었다.

description 쪽은 더 단순했다. 지금 문구에 "run-or-die-server"라는 서비스명이 어디에도 없었다. 여러 서버가 한 플랫폼에 등록돼 있을 때 이 설명만 보고도 어느 서비스의 툴인지 구분이 되라는 요구였던 것 같다.

수정

1
2
3
4
5
6
7
8
9
annotations: {
title: "마라톤 대회 일정 조회",
// 외부 API를 조회만 할 뿐 서버/DB 상태를 바꾸지 않는 읽기 전용 도구.
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
// 네이버 검색 API라는 서버 바깥(open-world) 데이터 소스를 사용한다.
openWorldHint: true
}

description은 맨 앞에 서비스명을 명시하는 문장을 붙였다.

1
2
- description: `네이버 공식 검색 API를 통해 실시간으로 ...`
+ description: `run-or-die-server의 GetRunnersHigh 도구입니다. 네이버 공식 검색 API를 통해 실시간으로 ...`

검증

고치고 바로 배포하지 않고, tsc --noEmit으로 타입부터 통과하는지 확인한 뒤, --stdio 모드로 서버를 직접 띄워서 진짜 JSON-RPC로 tools/list를 호출해봤다. 응답에 annotations 네 필드와 수정된 description이 그대로 찍히는 걸 확인하고 나서야 재배포, 재심사 신청으로 넘어갔다.

재배포가 생각보다 간단하지 않았다

annotations와 description을 고치고, tsc --noEmit과 로컬 stdio 테스트까지 통과한 걸 확인한 뒤 재배포했다고 믿고 넘어갔다. 그런데 나중에 실제 배포 서버(https://api-runnershigh.com/mcp)에 직접 JSON-RPC로 tools/list를 호출해보니, 여전히 예전 버전이 응답하고 있었다 — annotations도 없고, description에 서비스명도 없는 그대로였다.

로컬에서 고치고 테스트까지 통과했다고 해서 배포본에도 반영됐다는 보장은 없다는 걸, 재심사를 또 반려당하기 직전에 알아챈 셈이다.

클라우드타입 "Redeploy"는 최신 커밋을 가져오지 않는다

처음엔 대시보드의 배포 이력에서 ⋮ → Redeploy를 누르면 되는 줄 알았다. 그런데 이 버튼은 그 이력에 박혀 있는 커밋을 그대로 다시 배포하는 기능이었다 — 최신 커밋을 고르는 옵션이 아니라, 과거에 배포했던 시점을 그대로 재현하는 것에 가까웠다. 로컬에서 아무리 커밋을 쌓아도 이 버튼만으로는 영원히 예전 버전이 올라간다.

(처음엔 "설정 화면 하단에 배포하기 버튼이 있다"고 생각했는데, 실제 화면에는 그런 버튼이 없었다. 확인 없이 추측했던 부분이라 바로잡는다.)

CLI로 전환, 그런데 revision이 또 발목을 잡았다

결국 @cloudtype/cli(ctype apply)로 넘어갔다. 그런데 대시보드에서 내려받은 app.yaml에는 처음 배포했을 때의 커밋 해시가 context.git.revision으로 이미 박혀 있었다. CLI 소스를 열어보니, 이 값이 yaml에 있으면 -r 옵션으로 다른 커밋을 지정해도 무시되고 yaml에 적힌 커밋이 그대로 쓰이는 구조였다.

1
_.context = _.context || { git: { ...stage.git, revision: -r 값 } }

context가 이미 존재하면 -r은 평가조차 되지 않는다. yaml에서 revision 줄을 지우고, “push 먼저, ctype apply는 그다음” 순서를 지키는 걸로 정리했다.

1
2
3
npm version patch --no-git-tag-version   # package.json 버전을 올려서 반영 여부를 눈으로 확인할 수 있게
git add -A && git commit -m "..." && git push origin main
ctype apply -g

배포 후에는 serverInfo.version이 실제로 올라갔는지로 반영 여부를 확인하는 습관이 생겼다. 응답 내용만 보고 “고쳤겠지” 하고 넘어가지 않기로 한 것이다.

실제로 두드려보니, 진짜 문제는 따로 있었다

annotations와 description은 심사 기준을 만족시키기 위한 수정이었다. 그런데 배포된 서버에 실제 지역·연도 조합으로 tools/call을 여러 번 날려보니, 심사와는 별개로 도구 자체의 품질 문제가 여러 개 보였다.

증상 예시
지난 대회가 섞여 나옴 region=서울인데 2026-06-13(이미 지난) 성남 대회가 포함됨
지역이 안 맞음 region=화성인데 4위로 2024년 진주 대회가 나옴
입력 검증이 없음 targetYear="abcd"를 줘도 에러 없이 200 응답 — 제주4·3 PDF, 해외곡물시장 동향 같은 마라톤과 무관한 문서를 그대로 돌려줌
에러 코드가 스펙과 다름 없는 툴을 호출하면 -32603(내부 오류)을 반환 — JSON-RPC 스펙상 -32602(잘못된 파라미터)가 맞음

그중 가장 눈에 띈 건 따로 있었다. 응답 본문 맨 앞에 이런 문구가 박혀 있었다.

1
text: `[시스템 기준일: ${currentDate}]\n네이버 공식 API를 통해 실시간 조회한 원본 데이터입니다. 이 데이터를 기반으로 유저에게 현재 접수 가능한 마라톤 대회 정보(대회명, 개최일, 접수 상태 및 일정)를 깔끔하게 정제하여 마감 임박 순으로 안내해 주세요.\n\n${textContext}`

서버가 직접 필터링·정렬을 하는 대신, "이렇게 정리해서 안내해 줘"라고 호출하는 모델에게 지시를 떠넘기고 있었던 거다. 당장 편하자고 넣은 한 줄이었는데, 다시 보니 문제가 두 가지였다. 첫째, description에 "현재 날짜 이후"라고 써놔도 그걸 실제로 보장하는 쪽은 서버가 아니라 그때그때의 모델이었다 — 지난 대회가 섞여 나오는 게 당연했다. 둘째, 툴 응답 안에 모델을 향한 지시문이 들어 있는 모양새라, 심사자 입장에서는 프롬프트 주입(prompt injection) 패턴으로 오해할 여지도 있었다.

세 번에 걸쳐 고치다

한 번에 다 고쳐지지 않았다. 실제 네이버 검색 결과로 테스트할 때마다 새로운 구멍이 보였고, 그때마다 버전을 올려가며 고쳤다.

v1.6.0 — 날짜 필터, 입력 검증, 지시문 제거

src/filter.ts를 새로 만들어서 검색 결과 요약문에서 날짜를 정규식으로 뽑아내고, 오늘(한국 시간 기준) 이전이면 제외하도록 했다.

1
2
3
4
export function todayKST(now: Date = new Date()): { today: string; year: number } {
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul" }).format(now);
return { today, year: Number(today.slice(0, 4)) };
}

targetYear/region 입력값도 검증하게 했다. 패턴에 안 맞으면 isError: true로 명확하게 실패를 돌려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function validateArgs(raw: Record<string, unknown>, currentYear: number):
{ ok: true; year: string; region: string } | { ok: false; message: string } {
let year = String(currentYear);
if (raw.targetYear !== undefined && raw.targetYear !== null && raw.targetYear !== "") {
const y = String(raw.targetYear).trim();
if (!/^\d{4}$/.test(y)) {
return { ok: false, message: `targetYear는 YYYY 형식이어야 합니다. (입력값: ${y})` };
}
if (Number(y) < currentYear || Number(y) > currentYear + 1) {
return { ok: false, message: `targetYear는 ${currentYear} 또는 ${currentYear + 1}만 지원합니다. (입력값: ${y})` };
}
year = y;
}
// region도 20자 이내 한글/영문인지 같은 방식으로 검증
return { ok: true, year, region: "..." };
}

그리고 모델에게 "이렇게 정리해서 안내해 줘"라고 시키던 지시문은 걷어내고, 서버가 직접 필터링·정렬까지 끝낸 결과만 돌려주도록 바꿨다.

v1.7.0 — 지역 매칭

지역 필터가 아예 없다 보니 "화성"을 검색했는데 전혀 다른 지역 대회가 섞여 나왔다. 17개 시도별로 구·시 별칭을 표로 만들어서, 제목·요약·링크 어디에도 그 지역 키워드가 없으면 제외하도록 했다.

1
2
3
4
5
export const REGION_ALIASES: Record<string, string[]> = {
서울: ["서울", "seoul", "강남", "강동", "강북", /* ... */ "잠실", "상암", "광화문", "한강"],
경기: ["경기", "수원", "성남", "고양", /* ... */ "화성", "평택"],
// 17개 시도 전부 등록
};

표에 없는 지명(화성시, 춘천 같은 시·군)은 “시/군/구” 접미사만 떼고 그대로 매칭하게 해서, 모든 지명을 일일이 등록하지 않아도 되게 했다. 같은 이름의 구(중구·동구 등)는 시도를 가리지 않고 겹치는 경우가 많아서, 오탐을 막으려고 아예 표에서 뺐다.

v1.8.0 — 연도 필터가 사실상 안 걸리고 있었다

v1.7.0을 실제로 등록해서 써보다가 더 근본적인 문제를 발견했다. targetYear를 입력하지 않으면 연도 제한이 아예 없어서, 연도를 전혀 알 수 없는 결과(충주, 춘천 등)가 전부 "일정 미확인"으로 통과되고 있었다. "26년 11월 8일"처럼 두 자리로 쓴 연도도 못 알아봤고, 링크 안에만 연도가 박혀 있는 경우(...-2025-09-28)도 놓쳤다.

1
2
// 26년 11월 8일 처럼 연도를 두 자리로 쓴 경우
const short = /(?<!\d)(\d{2})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;

연도가 아예 없는 결과는 "연도 확인 불가"로 분류해서 빼고, 링크도 URL 디코딩해서 날짜·연도를 다시 추출하도록 고쳤다. 벚꽃·여름처럼 계절로만 표현된 대회는 그 계절이 이미 지났으면 제외하는 규칙도 추가했다.

정리

annotations 필드 하나, description 문구 하나 고치는 걸로 끝날 줄 알았는데, 실제로는 “로컬에서 고친 게 배포에 반영됐는지 확인하는 것” 자체가 하나의 과제였고, 배포가 확인된 뒤에도 실제 트래픽으로 두드려봐야만 보이는 버그들이 따로 있었다. annotations처럼 심사 기준에 맞추는 일과, 날짜·지역 필터처럼 도구가 실제로 쓸만한지는 서로 다른 문제라는 걸 체감했다.

가장 기억에 남는 건 응답에 섞여 있던 "유저에게 … 안내해 주세요"라는 한 줄이다. 서버가 책임져야 할 로직을 모델에 떠넘기면 당장은 편하지만, 그 결과를 서버가 보장할 수 없게 되고, 심사자 눈에는 프롬프트 주입처럼 보일 수도 있다는 걸 이번에 알았다. 작은 툴 하나짜리 서버였지만, 명세를 맞추는 것과 실제로 신뢰할 수 있게 동작하는 것 사이의 거리를 제대로 느껴본 경험이었다.