
공용 렌탈 서버의 WordPress만으로 MCP 서버를 구축하여 ChatGPT 앱 심사를 통과한 이야기 (그리고 일본어 쿼리로 겪은 시행착오)
요약
공용 렌탈 서버의 WordPress mu-plugin을 활용하여 별도의 인프라 구축 없이 MCP 서버를 구현하고 ChatGPT 앱 심사를 통과한 사례를 소개합니다. WAF 차단 문제를 해결하기 위한 설치 전략과 일본어 쿼리 처리 시의 시행착오를 다룹니다.
핵심 포인트
- WordPress mu-plugin을 활용해 추가 서버 없이 MCP 서버 구축 가능
- WAF의 <script> 포함 POST 요청 차단 문제를 파일 직접 설치로 회피
- ChatGPT 앱 심사 통과를 위한 MCP 프로토콜 구현 경험 공유
- 일본어 쿼리 사용 시 도구 반환 오류에 대한 주의사항
TL;DR
- 중고 바이크 매입점의 업무 시스템을 사쿠라 렌탈 서버(공용) + WordPress의 mu-plugin만으로 MCP 서버로 만들었다. 새로운 서버는 단 한 대도 구축하지 않았다.
- MCP 공식 레지스트리에 등록하여 ChatGPT 앱 심사를 통과했다 (2026-07-27 제출 → 07-28 승인 → 07-30 게재 확인).
- 본론은 후반부. 일본어 쿼리로 인해 툴이 0건을 계속 반환했던 이야기와 그 해결 방법. "AI가 똑똑하니까 모호한 일본어라도 괜찮겠지"라는 생각은 통하지 않았다.
- 구현은 PHP. 엔드포인트는
https://shonanfarm.com/wp-json/zama/v1/mcp로 공개 중이다 (인증 없음·읽기 2건/쓰기 1건).
대상 독자는 "자신의 서비스를 MCP에 대응시키고 싶지만, 전용 인프라를 구축할 정도는 아닌" 사람이다. 특히 일본어로 사용되는 툴을 만드는 사람이라면 후반부만이라도 읽어보길 권한다.
구성: 왜 mu-plugin인가
ChatGPT / Claude
│ JSON-RPC over HTTPS (Streamable HTTP)
▼
...
Node로 MCP SDK를 사용하는 것이 일반적이라고 생각하지만, 그렇게 하면 서버가 한 대 늘어난다. 이번에는 다음과 같은 이유로 WordPress로 결정했다.
- 사이트 자체가 이미 WordPress로 구동 중이며, SSL과 도메인, 운영 체계가 갖춰져 있다.
- 시세 데이터(842종 × 상태 5단계 = 4,990행)를 이미 WordPress 측에서 보유하고 있었다.
- 공용 서버이므로 애초에 상주 프로세스를 띄울 수 없다.
WordPress의 플러그인에는 세 종류가 있지만, **mu-plugin (must-use plugin)**을 선택했다. wp-content/mu-plugins/에 .php 파일을 두기만 하면 자동으로 활성화되며, 관리자 화면에서 비활성화할 수 없는 형식이다.
선정 이유는 적극적이라기보다 소극적이었는데, 관리자 화면을 거치지 않고 파일 설치만으로 완결되기 때문이다. 이유는 다음 장의 WAF에 있다.
if (!defined('ABSPATH')) { exit; }
if (!function_exists('zama_mcp_handle')) { // 이중 정의 시에도 즉사하지 않기 위한 가드
add_action('rest_api_init', function () {
...
<script>를 포함한 POST를 차단하는
시행착오 ①: 사쿠라 렌탈 서버의 WAF는 <script>를 포함한 요청 본문(Request Body)의 POST를 403으로 차단한다. 이것이 까다로운 이유는 WordPress의 관리자 화면에서 코드를 저장하는 조작도 POST이기 때문이다.
즉, "관리자 화면에 PHP 스니펫을 붙여넣고 저장"하는 작업이 에러 메시지 없이 실패한다. 저장 버튼을 눌러도 아무 일도 일어나지 않는다. 내 코드가 잘못된 것인지 서버가 차단하는 것인지 구분할 수 없는 상태가 가장 힘들다.
회피책으로 효과가 있었던 것은 세 가지다.
- 코드는 관리자 화면에 붙여넣지 않고 파일로 설치한다 (= mu-plugin을 선택한 이유)
- 어쩔 수 없이 관리자 화면을 사용해야 한다면, 임포트 기능(파일 업로드)을 경유한다.
- 이미지를 REST API로 보낼 때는 생 바이너리가 아니라
multipart/form-data로 한다. EXIF를 제거하면 통과되는 경우도 있다.
MCP 서버 본체는 "파일을 두는 것"만으로 완결되므로 이 문제를 근본적으로 피할 수 있었다. 공용 서버에서 MCP를 구현한다면, 처음부터 관리자 화면을 거치지 않는 설치 방법을 선택하는 것이 정답이라고 생각한다.
구현: MCP로서 최소한으로 필요한 것
MCP (프로토콜 버전 2025-06-18)에서 우선 응답해야 하는 것은 initialize와 tools/list 두 가지다. 그 외에 tools/call을 구현하면 동작한다.
if ($method === 'initialize') {
return zama_mcp_result($id, array(
'protocolVersion' => $pv,
...
instructions는 효과가 있다. 여기에 "사용하지 말아야 할 상황"까지 적어두면 오작동이 눈에 띄게 줄었다. 우리 경우에는 "바이크 구매 상담·정비·부품·커스텀 질문에는 이 툴을 사용하지 말 것"이라고 명시하고 있다.
툴의 annotations는 심사 요건이기도 하다
'annotations' => array(
'title' => 'バイク車種を検索',
'readOnlyHint' => true,
...
- **ChatGPT 앱 제출 시**, Scan Tools가 `destructiveHint`의 누락을 경고하며 반려함
- **Claude의 Connectors Directory**도 `title`과 `readOnlyHint` / `destructiveHint`를 필수 사항으로 규정함
- 읽기 전용(Read-only) 툴과 쓰기 전용(Write) 툴은 **분리해 두어야 함** (심사 시 분류됨)
우리는 3개로 압축했다. 다기능으로 만들수록 심사와 운영의 부담이 커진다.
| 툴 | 종별 | 역할 |
|---|---|---|
| `search_models` | 읽기 | 모호한 표현으로부터 842개 차종의 정식 명칭을 특정 |
| `get_price` | 읽기 | 상태별 5단계의 대략적인 시세를 반환 |
| `submit_kaitori_request` | 쓰기 | 출장 감정 예약 전송 |
### 도메인 소유 확인도 mu-plugin으로 반환
ChatGPT 앱 심사에서는 `.well-known`에 토큰을 두어 도메인 소유를 증명한다. 실제 파일을 두면 설치 위치를 틀리기 쉬우므로, 이 또한 mu-plugin으로 반환하도록 했다.
```php
add_action('parse_request', function ($wp) {
if ($wp->request === '.well-known/openai-apps-challenge') {
header('Content-Type: text/plain');
...
동의 없이 쓰기는 할 수 없다 (사양)
MCP 사양(2025-06-18)에서는 **툴 실행 전에 사용자의 명시적인 동의를 구하는 것이 필수(MUST)**이다. 따라서 "AI가 알아서 예약을 보낸다"는 사양상 불가능하다.
처음에는 "완전 자동이 아닌가"라며 실망했지만, 실제 운영 측면에서는 이것이 옳다. 출장 매입은 차량을 직접 보러 가는 사업이며, 한 건의 헛걸음이 실제 이동 시간과 기름값으로 이어지기 때문이다. 서버 측에서도 confirm 플래그를 필수 항목으로 두어 이중으로 확인하고 있다.
본론: 일본어 쿼리로 겪은 3가지 지뢰
여기서부터가 이 글에서 가장 쓰고 싶었던 부분이다. "AI가 똑똑하니까 다소 허술한 검색 구현이라도 자연어를 잘 처리해주겠지"라는 생각은 통하지 않는다는 이야기다.
지뢰 1: 복합 쿼리에서 0건이 나옴
첫 번째 구현은 쿼리 전체가 차종명이나 제조사명의 부분 문자열(Substring)일 것을 요구했다. 그랬더니 ChatGPT가 던지는 "PCX150 ホンダ" (차종명 + 제조사명을 하나의 문자열로 합친 형태)가 반드시 0건이 된다.
0건이 반환되면 어떤 일이 벌어지는가. AI는 "이 서비스에는 정보가 없다"라고 판단하여, 대신 일반 지식이나 경쟁 사이트의 정보로 답변한다. 자신의 창구를 만들어 놓고, 그 창구의 결함 때문에 경쟁사를 소개하게 만든 셈이다.
대책으로서, 우선 공백(반각/전각/가운데점/쉼표)으로 분할하여 AND 검색을 하도록 만들었다.
$norm = str_replace(array(' ', "\t", '、', ','), ' ', $q);
$terms = array_values(array_filter(array_map('trim', explode(' ', $norm))));
foreach ($idx as $r) { // $r = [차종명, 제조사, 클래스]
...
이로써 복합 쿼리 문제는 해결되었다. 하지만 이것만으로는 부족했다.
지뢰 2: 조사로 연결된 일본어는 "분할할 수 없다"
프레스 릴리스에 올리기 직전, 간판으로 내세웠던 예문을 실제 기기에 던져보고 얼굴이 창백해졌다.
| 입력 | 결과 |
|---|---|
原付のスクーターで、たしかホンダの125 | 0건 |
ホンダの125のスクーター | 0건 |
ホンダ125 | 0건 |
ホンダ 125 スクーター | 25건 (단, 앞부분이 Ape50·CB50 등 50cc뿐임) |
이유는 단순하다. 일본어는 조사로 연결되기 때문에 공백으로 나눌 수 없다. ホンダの125のスクーター는 하나의 단어로 취급되어, 어떤 차종명이나 제조사명과도 부분 일치하지 않는다.
그렇다면 조사("の", "で", "を" 등)를 구분자로 쓰면 되는가? 이것은 절대로 해서는 안 될 행동이다.
「ジョルノ」 → 「ジョル」+「」 ← 차종명이 망가짐
「ソロ」 → 「ソ」+「」 ← 위와 동일
일본어 차종명에는 조사와 같은 글자가 보통 포함되어 있다. 형태소 분석 (Morphological Analysis)을 도입하는 방법도 있지만, 공용 서버에 MeCab을 설치할 수는 없다.
인덱스 측의 단어를 쿼리 안에서 찾기
해법: 쿼리를 나누는 것이 아니라, 발상을 반대로 했더니 깔끔하게 풀렸다. 쿼리를 분해하는 것을 그만두고, 가지고 있는 인덱스에 있는 단어가 쿼리 문장 안에 출현하는지를 보는 것이다. 이 방향이라면 애초에 분할하지 않으므로 차종명이 망가지지 않는다.
문장에서 추출하는 것은 다음 4가지 종류만 있으면 된다.
별명 사전에 등록된 호칭
인덱스에 존재하지 않는 표현만을 등록해 둔다 (Ape → Ape, 슈퍼 커브 → 커브, 비노 → 비노, 캐노피 → 자이로 …).
책상 위에서 만들지 말고, 실제 기기에 던져서 0건이었던 단어를 모아서 만드는 것이 빠르다. 사용자의 말투는 상상만 해서는 맞출 수 없다.
인덱스에 실제로 존재하는 명칭 그 자체
「ジョルノを売りたい(조르노를 팔고 싶다)」 안에 「ジョルノ(조르노)」가 포함되어 있는지를 본다. 여러 개가 맞으면 가장 긴 것을 하나만 채택한다.
⚠️단, 3글자 미만은 대상에서 제외할 것. 우리 인덱스에는 「イブ(이브)」, 「ソロ(솔로)」, 「ギア(기어)」, 「ハイ(하이)」, 「ラン(란)」 같은 짧은 차종명이 있어서, 일반적인 일본어 문장에 섞여 오작동(False Positive)할 수 있다. 짧은 이름은 앞 단계의 공백 구분 검색으로 잡을 수 있으므로, 놓치는 데이터(Miss)가 되지는 않는다.
영문 덩어리
「2005년식 PCX」의 PCX를 정규 표현식 (Regular Expression)으로 추출한다. 인덱스의 정식 명칭은 PCX125이므로 완전 일치(Exact Match)는 하지 않지만, 단서로서는 충분하다.
배기량 숫자
125 → 소형(51~125cc)과 같이 클래스로 변환한다.
⚠️연식 범위(1900~2100)는 반드시 제외할 것. 그렇지 않으면 「2005년식」의 2005를 배기량으로 해석해 버린다.
숫자를 가져올 수 없다면, 카테고리 용어(원동기 2종 / 경형 이륜 / 대형 …)로부터 추정한다.
그리고, 이 부분이 가장 효과적이었다.
추출한 후보 중,
실제로 인덱스에 매칭되는 단어만을 조건으로 남긴다.
「スクーター(스쿠터)」, 「たしか(아마도)」와 같이, 이쪽 데이터에 존재하지 않는 단어를 조건에 넣으면 그 단어 때문에 모든 결과가 제외된다.
후보를 모은 뒤에 인덱스에 대조하여 걸러내는 한 단계를 추가함으로써 정밀도(Precision)가 크게 변했다.
포인트는 3가지다.
쿼리를 나누지 않는다. 인덱스 측의 단어를 찾아가기 때문에 차종명이 망가질 리가 없다.
인덱스에 맞지 않는 단어는 버린다. 「スクーター(스쿠터)」, 「たしか(아마도)」처럼 이쪽 데이터에 존재하지 않는 단어를 조건으로 남기면 전멸한다.
숫자는 클래스로 변환한다. 「125」를 차종명으로 찾아도 맞지 않지만, 배기량 클래스로라면 효과가 있다.
결과는 다음과 같았다.
| 입력 | 수정 전 | 수정 후 |
|---|---|---|
原付のスクーターで、たしかホンダの125 (원동기 스쿠터로, 아마 혼다의 125) | 0건 | 74건 (혼다의 125cc급. PCX125, 리드125, Dio110… ) |
ホンダ125 / ホンダの125のスクーター | 0건 | 위와 동일 |
スーパーカブ110 (슈퍼 커브 110) | 0건 | 3건 (커브110, 크로스 커브110, CT110) |
ジャイロキャノピー (자이로 캐노피) | 0건 | 자이로(X/캐노피) |
エイプ100 (에이프 100) | 0건 | Ape100 (1건으로 압축 가능) |
2005年式のPCX (2005년식 PCX) | 0건 | PCX125 / PCX150 / PCX160 |
ジョルノを売りたい (조르노를 팔고 싶다) | 0건 | ジョルノ (차종명이 망가지지 않음) |
車を売りたい (차를 팔고 싶다) | — | 0건 (오검출하지 않음) |
참고로, 기존 경로(PCX150, CB400 등)는 먼저 기존의 AND 검색을 통과하도록 하여 동작을 바꾸지 않았다. 새로운 해석은 「AND 검색이 0건일 때만」 동작한다.
지뢰 3: 건수가 많을 때 상위 N건에서 끊으면 본래 목적물이 사라진다
原付のスクーターで、たしかホンダの125는 74건 히트한다. 당초에는 25건에서 끊었으나, 정렬 순서가 알파벳 순이라 ATC70 → Ape100 → BENLY110 → C92 → CB… 로 채워져, 본래 목적인 PCX125가 표시 범위 밖으로 밀려나 있었다.
AI에게 전달할 문자열이므로, 인간을 위한 페이지네이션 (Pagination)과는 사정이 다르다. 제조사와 클래스는 공통 조건이므로 생략하고, 차종명만 전체를 나열하는 편이 정보량이 더 많다.
if ($total <= 25) {
// 「차종명 (제조사·클래스)」 형식으로 그대로 반환
} else {
...
마지막 문장을 추가함으로써, AI가 "PCX인가요, 리드인가요?"라고 사용자에게 되묻게 되었다. 도구(Tool)의 반환값은 AI에게 지시를 내릴 수 있는 공간이기도 하다.
테스트를 어떻게 작성했는가 (PHP가 수중에 없는 문제)
운영 환경의 mu-plugin은 구문 오류(Syntax Error) 하나만으로 사이트 전체가 다운된다. 하지만 내 Windows PC에는 PHP가 설치되어 있지 않았다. 그래서 두 단계로 나누어 준비했다.
- 간이 구문 체크(Syntax Checker)를 Python으로 작성했다. 문자열 리터럴과 주석, heredoc을 처리한 후, 괄호 대응·함수의 중복 정의·전각 공백(Full-width space) 혼입을 검출하도록 했다.
php -l을 대신하기에는 충분히 기능했다. - 로직을 Python으로 이식하여, 실제 데이터 842개 차종으로 25개 패턴을 돌렸다. 기대하는 차종이 반환되는지, 기존 동작이 변하지 않았는지, 무관한 쿼리에서 오검출(False Positive)이 발생하지 않는지 확인한 후 운영 환경에 반영했다.
운영 반영 후에도 사이트 생존 여부(Liveness)·버전·수정 대상 쿼리·회귀(Regression)·오검출의 5개 항목을 자동으로 실행하여 확인하고 있다. 공용 서버라 스테이징 환경(Staging Environment)이 없는 경우, 이 "반영 전 수중에서 통과시키기 / 반영 직후 외부에서 호출하기"라는 두 가지 전략이 현실적인 타협점이라고 생각한다.
운영하며 알게 된 점
ChatGPT에서는 매 메시지의 맨 앞에서 @ 멘션을 하지 않으면 도구가 호출되지 않는다. 첫 번째 메시지에서 지정해도 두 번째 메시지에는 적용되지 않는다. 전각 @는 사용할 수 없다. 이 부분은 내가 직접 겪어보고 나서 설명 페이지에 붉은 글씨로 적어두었다. 사용자는 반드시 여기서 막히게 된다.
"대답할 수 없다"가 가장 무섭다. 검색 엔진이라면 검색 결과가 0건일 때 "찾을 수 없습니다"로 끝나지만, AI는 결과가 0건이면 다른 곳의 정보로 대답해 버린다. 누락 패턴에 대한 테스트는 기능 추가보다 우선순위가 높다.
도구 설명과 구현이 어긋나지 않았는지 확인한다. 우리 사례는 설명문에 예시로 "4미니"라고 적어두었으나, 실제 구현은 0건을 반환하고 있었다. 설명문은 AI가 읽고 도구를 선택하는 재료이므로, 작성한 예시는 반드시 통과하도록 만들어 두어야 한다.
요약
- 공용 렌탈 서버 + WordPress로도 MCP 서버는 성립한다. 관리자 화면을 거치지 않는 설치 방법(mu-plugin)을 선택하는 것이 WAF(Web Application Firewall) 대응에 효과적이다.
- annotations (
title/readOnlyHint/destructiveHint)는 심사 요건이다. 읽기/쓰기 도구는 분리한다. - 일본어 쿼리는 공백으로 분리할 수 없다. 조사로 분리하면 차종명이 망가진다. 인덱스(Index) 측의 단어를 쿼리 문장에서 찾는 방향으로 설계하면 망가지지 않고 해결할 수 있다.
- 인덱스에 걸리지 않는 단어는 조건에서 버린다. 남겨두면 전체 결과가 제외된다.
- 0건은 AI에게 "다른 곳으로 흘러가게 만드는" 트리거다. 누락 테스트를 최우선으로 한다.
엔드포인트는 공개되어 있으므로, 실제 동작은 아래에서 확인할 수 있다.
curl -s -X POST https://shonanfarm.com/wp-json/zama/v1/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
관련 링크
- MCP 서버:
https://shonanfarm.com/wp-json/zama/v1/mcp - 공식 MCP 레지스트리:
com.shonanfarm/zama-bike-kaitori - 설명 페이지: https://shonanfarm.com/bike-ai-satei/
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기