MCP 서버는 SDK나 Node.js가 없어도 만들 수 있습니다. 이 기사에서 다루는 서버는 PHP 파일 4개(Composer도, SDK도 사용하지 않음)로 구성되어 있으며, 일반적인 렌탈 서버(Xserver) 위에서 작동하며 Claude의 커스텀 커넥터로부터 바로 호출할 수 있습니다.
다루는 주제는 웹 툴 모음인 'KitBench'의 계산 도구입니다. 제작 방법과 이식된 코드가 원본과 동일한 답을 반환하는지 확인한 방법을 정리했습니다.
왜 만들었나
AI에게 다음과 같은 질문을 하면 그럴듯한 답변이 돌아오지만, 세부적인 부분에서 틀릴 수 있습니다.
- '12월 25일로부터 5 영업일 후는 언제인가?' (일본의 공휴일 및 대체 휴일을 정확히 제외하는지)
- '쇼와 63년 5월 3일생은 오늘로 몇 살인가?' (일본 연호 → 서력 → 만 나이 계산)
- '이 글은 몇 자인가?' (이모지나 결합 문자를 보이는 한 글자로 셀 수 있는지)
이러한 계산은 답이 하나로 정해지는 결정적인 처리입니다. AI가 생각하게 하는 것보다, 정확하게 만든 함수를 AI로부터 호출시키는 것이 더 효과적입니다. 이를 위한 표준 메커니즘이 MCP(Model Context Protocol)입니다.
7가지 도구
| 도구 | 내용 |
|---|
count_characters | 일본어 글자 수 (보이는 한 글자・공백 제외・전각/반각・원고지 매수・UTF-8 바이트 수 등) |
days_between | 두 날짜 사이의 일수・주・년월일, 일본 영업일(토요일/일요일 공휴일 제외) 및 토요일/일요일만 제외한 일수 |
add_days | ◯일 후・◯영업일 후(전)의 날짜, 요일・일본 연호 포함 |
calculate_age | 만 나이・년월일・세는 나이・다음 생일・간지 등 |
convert_japanese_era | 서력과 일본 연호(메이지~레이와) 간 상호 변환. 1년차는 '원년' |
consumption_tax | 소비세 (세전↔세후・10%/8%/임의 세율・자릿수 처리). 정수로 계산 |
convert_units | 단위 환산 (평・다다미・자(尺)・합 등 일본 단위 포함) |
파일을 다루는 도구(PDF・이미지)는 넣지 않았습니다. 모두 'AI가 스스로 틀리기 쉬운 계산'에 초점을 맞췄습니다.
실제로 호출하면 다음과 같은 답변이 돌아옵니다.
curl -s -X POST https://kitbench.net/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
...
{
"jsonrpc": "2.0",
"id": 3,
...
12월 28일~31일의 4일과 원년(공휴일)을 제외한 1월 4일이 5 영업일째입니다. 여기서 '영업일'은 토요일/일요일과 내각부 목록에 있는 공휴일・휴일을 제외한 날이며, 회사별 연말연시 휴일은 포함되지 않습니다.
days_between
의 경우, 이 규칙을 답변 내용의 business_days_rule에 문장으로 넣어 반환합니다(AI가 이용자에게 그대로 설명할 수 있도록). add_days
의 답변에는 현재 이 항목이 없습니다.
Claude와 연결하기 (먼저 시도하고 싶은 사람용)
claude.ai의 커스텀 커넥터에 추가할 수 있습니다(사이트 사용법 페이지에 적힌 절차입니다. 화면 이름은 변경될 수 있습니다).
- claude.ai에서 'Customize(커스터마이즈)' → 'Connectors(커넥터)' 열기
- '+ Add' → 'Add custom connector(커스텀 커넥터 추가)'
- 이름에 'KitBench', URL에
https://kitbench.net/mcp를 입력하고 'Continue' - 인증은 'No sign in(사인인 없음)'을 선택하고 'Add'
- 대화창 왼쪽 아래의 '+' → 'Connectors'에서 KitBench를 켜기
연결한 후,
)。이 기간에는 공휴일이 3일(10월 12일, 11월 3일, 11월 23일) 있으므로, 주말만 제외한 56일에 대해 영업일은 53일이 됩니다. 시작일을 포함하고 싶을 때는 include_start_day를 true로 설정합니다.
프로그램에서 사용하고 싶다면, 같은 계산을 일반 HTTP API(https://kitbench.net/kb-api/v1.php)에서도 호출할 수 있습니다.
서버 구축: Streamable HTTP · JSON-RPC · SSE 미사용
MCP의 통신 방식 중 원격 서버에서 사용할 Streamable HTTP 방식으로 만들었습니다. PHP 8.0의 순수 스크립트 1개(+"계산 부품")로, SDK는 사용하지 않았습니다.
정해진 사항은 다음과 같습니다.
| 항목 | 이 서버 구축 내용 |
|---|
| 받는 메서드 | POST만 가능 (GET은 405, 프리플라이트의 OPTIONS는 204) |
| 반환 방식 | application/json으로 JSON을 하나 반환합니다. SSE(스트림)는 사용하지 않습니다. |
| 세션 | 유지하지 않음 (Mcp-Session-Id를 발행하지 않음) |
| 알림 (id가 없는 메시지) | 응답 본문 없이 202 |
| 모아서 전송된 경우 (배열) | 하나씩 처리하여 배열로 반환 |
| 지원 메서드 | initialize · ping · tools/list · tools/call |
| CORS | Access-Control-Allow-Origin: *, 메서드는 POST·OPTIONS, 헤더는 Content-Type · Accept · Mcp-Session-Id · MCP-Protocol-Version 허용 |
| 너무 큰 요청 | 2MB 초과 시 413 |
| JSON으로 읽을 수 없는 경우 | HTTP 200에 JSON-RPC의 Parse error(-32700) |
| 횟수 제한 | 동일 접속원으로부터 10분당 300회까지 |
도구는 전부 "계산해서 반환만 하는" 것이므로, 중간 경과를 흘릴 필요도, 상태를 가질 필요도 없습니다. 따라서 SSE도 세션도 가지 않는 가장 단순한 형태로 만들었습니다.
핵심 분기 처리는 거의 이것뿐입니다.
// kb-api/mcp.php에서 (요약)
function mcp_ok($id, $result) { return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]; }
switch ($m['method']) {
...
세부적인 점 몇 가지.
capabilities의 tools는 빈 객체입니다. PHP의 빈 배열 []은 JSON에서 []이 되어버리므로, new stdClass()를 전달하여 {}로 만들었습니다. - 도구에는 annotations로 "읽기 전용 · 아무것도 망가뜨리지 않음 · 같은 입력이면 같은 답 · 외부 세계에 접촉하지 않음"을 붙였습니다. Claude의 커넥터 화면에서는 7개가 "읽기 전용" 도구로 나열되었습니다. - tools/call의 결과는 텍스트(JSON 문자열)와 structuredContent 양쪽으로 반환합니다. - 인자 오류(날짜 형태가 다르거나 범위 외 등)는 JSON-RPC 에러가 아니라, isError: true의 결과로 반환합니다. AI가 그 문을 읽고 인자를 고쳐 다시 호출할 수 있도록 하기 위함입니다. - 인증은 없어 공개되어 있으므로, 남용 방지 대책은 위의 횟수 제한뿐입니다. 전송된 문자나 날짜는 저장하지 않습니다.
가장 중요하게 생각한 점: 사이트의 JS와 "같은 답"
Kitbench에는 원래 같은 계산을 하는 페이지(브라우저의 JS)가 있습니다. MCP의 답이 페이지의 답과 어긋나면, "사이트에서 본 답과 AI의 답이 다르다"는 문제가 생깁니다. 그래서 답의 정본은 사이트의 JS로 결정하고, PHP는 그것을 옮겨 놓은 것이라는 위치를 부여했습니다.
옮길 때 주의한 점
- 숫자 표기: JS의
toPrecision이나 toLocaleString과 같은 결과를 내기 위해, 반올림 처리를 직접 작성했습니다. double을 정확한 10진수 자릿수로 전개한 다음, 딱 절반을 큰 쪽으로 반올림했습니다 (코드 주석에서는 PHP의 sprintf
마무리(반올림) 방식의 차이가 이유로 적혀 있습니다) -
JSON 소수점: 서버 PHP의 serialize_precision
이 100으로 설정되어 있어, 0.1
이 0.1000000000000000055…
처럼 나타났습니다. ini_set('serialize_precision', '-1')
로 수정했습니다 -
보이는 한 글자(grapheme): 사이트는 Intl.Segmenter
을 사용합니다. PHP 측에서도 동일한 구분을 위해 node에서 모든 코드 포인트를 하나씩 처리하고, PHP는 그 표와 구분 규칙으로 계산하도록 만들었습니다. Intl.Segmenter
를 거쳐 분류표를 만듭니다 -
공휴일: 내각부의 '국민의 공휴일에 대하여' CSV에서 만든 사이트의 공휴일표(JS)를 스크립트로 그대로 PHP 배열에 변환하고 있습니다. 공휴일표가 업데이트되면, PHP 측도 다시 만들어야 합니다. 발표되지 않은 미래 연도의 경우, 답변에 '그 이후는 주말만 제외했다'라는 주석을 달아줍니다 -
와레키(和暦)의 경계: 사이트의 JS는 Intl.DateTimeFormat
의 ja-JP-u-ca-japanese를 사용합니다. 그 결과에 맞춰, 쇼와 시대의 시작은 1926-12-25로 설정했습니다. 1873년 1월 1일 이전(구력 시대)은 다루지 않고 오류 처리했습니다.
검증: 16만 건 이상을 대조하여 불일치 0
'같은 답변'을 확인하기 위해 다음 절차로 대조했습니다.
- 기대값을 사이트의 실제 JS로 생성: 페이지의 JS 파일을 node의
vm으로 읽어 들여, DOM 부분(getElementById 등)만 목(mock)으로 대체하여 작동시킵니다. 입력란에 값을 넣고 계산시켜 화면에 나올 문자열을 수집합니다 -
같은 입력을 PHP에 전달: 서버와 동일한 PHP 8.0에서 kb_call_tool()
을 호출합니다 -
화면 표시 형태에 맞게 비교: PHP의 답변을 페이지가 내보내는 표시 형태('1,234', '약 3분' 등)로 수정한 후, 문자열로서 완전 일치 여부를 확인합니다.
// expect.js에서 (요약): 실제 파일을 vm으로 실행
const ctx = makeCtx(); // document.getElementById 등의 최소한의 목
vm.runInContext(fs.readFileSync(SITE + '/_src/kb_tools/character-count/tool.js', 'utf8'), ctx);
...
입력은 경계가 되기 쉬운 값(윤년・원호 전환・정확히 반의 반올림・이모지 결합 등)과 정해진 시드에서 만든 난수 두 가지 모두입니다. 와레키는 다루는 범위의 매일을 변환하기 때문에, 건수의 대부분(약 12만 건)은 이것이 차지합니다.
| 도구 | 처리 건수 | 불일치 |
|---|
| count_characters | 464 | 0 |
| ... | 합계 | |
| 163,761 | | |
| 0 | | |
'불일치 0'을 신뢰하기 위해: 일부러 고장 내기
모두 일치했을 때 가장 무서운 것은, 테스트 자체가 망가져 있어서 무엇이든 일치한다고 말하는 경우입니다. 그래서 PHP 측을 일부러 망가뜨려서 테스트가 발견하는지 확인했습니다(음성 대조).
| 일부러 고장 낸 부분 | 테스트가 발견한 불일치 |
|---|
| 유효숫자 반올림 (정확히 반은 올리지 않기) | convert_units 295 |
| ... | |
| 9군데 모두에서 불일치가 나왔습니다. '쇼와 시대 시작일을 하루 뒤로 미루는' 것처럼, 16만 건 이상 중에서 단 1건 차이만 나는 고장 방식도 있었습니다. 경계 값을 입력으로 넣어두지 않으면 놓칠 수 있다는 점을 잘 알게 되었습니다. | |
아울러, MCP의 형태(JSON-RPC의 오류・알림・배치・메서드 차이 등) 테스트를 57개 항목 동안 php-cgi로 실제 HTTP처럼 통과시켰습니다. 공개 후에는 공식 MCP Inspector(CLI・HTTP)로 실시간 목록 및 호출도 확인했습니다.
실패: Claude와 연결하니 400 에러 발생
공개 후, Claude의 커스텀 커넥터에 연결하려 했을 때, '서버에 연결하고 있습니다' 이후 400으로 실패했습니다.
원인은 MCP-Protocol-Version
헤더 처리 방식이었습니다. 첫 버전에서는 알고 있는 버전(2025-06-18
과 2025-03-26
)이외가 오면 400으로 거부하고 있었습니다. Claude로부터의 연결은 2025-11-25 버전으로 왔고, 여기서 막히곤 했습니다(요청 자체의 기록은 남기지 않았습니다).
2025-11-25
을 대응에 추가하자 연결이 되었습니다). MCP 사양(Transports의 'Protocol Version Header')에서는 서버가 유효하거나 미지원하는 MCP-Protocol-Version을 받으면 **400 Bad Request를 반환해야 한다(MUST)**고 되어 있습니다. 즉, 처음에 거부했던 것 자체는 사양대로였으며, 원인은 '대응하는 버전 목록이 오래되었기' 때문입니다.
수정한 부분은 2가지입니다.
// kb-api/mcp.php에서
const MCP_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
$pv = $_SERVER['HTTP_MCP_PROTOCOL_VERSION'] ?? '';
...
- 대응하는 버전에
2025-11-25를 추가함 (이렇게 하면 사양대로 연결됨) - 헤더의 버전이 모르는 값이라도 거부하지 않도록 함
두 번째는 사양에 맞지 않는 선택입니다. 이 서버는 initialize
・ping
・tools/list
・tools/call
만 사용하며, 버전이 올라가도 적어도 지금 버전까지는 이 부분의 형태가 바뀌지 않았기 때문에, 다음에 새로운 버전의 클라이언트가 왔을 때 똑같이 막히는 것보다 응답하는 쪽을 선택했습니다. 사양에 엄격하게 맞추고 싶다면 첫 번째만 하고, 새로운 버전이 나올 때마다 목록에 추가하는 것이 올바른 방식입니다.
initialize
에서는 클라이언트가 보낸 버전을 알고 있다면 그 버전을, 모르면 이쪽의 가장 최신 버전을 반환하여 어떤 버전으로 대화할지 결정합니다.
교훈은, 사양의 버전은 생각보다 빠르게 발전한다는 것과, 자신이 테스트한 클라이언트(Inspector)에서 통하더라도 실제 상대방(Claude)이 같은 버전으로 올 것이라고 단정할 수 없다는 것입니다. 실제 상대방과 연결하여 확인하는 것까지가 테스트라고 생각합니다.
공식 MCP Registry 등록: 도메인 소유자 확인 방법
MCP의 공식 목록(MCP Registry)에도 net.kitbench/kitbench라는 이름으로 등록했습니다. 네임스페이스의 net.kitbench는 도메인을 역순으로 만든 형태로, 해당 도메인의 소유자임을 보여줘야 합니다.
방법은 몇 가지가 있지만, 이번에는 HTTP로 표시하는 방식을 사용했습니다.
- ed25519 키 쌍을 만듦
- 공개키를
https://kitbench.net/.well-known/mcp-registry-auth에 v=MCPv1; k=ed25519; p=<공개키> 형태로 배치함 - 비밀키로 Registry에 인증하여, server.json을 공개함
배치한 공개키와 다른 키로 인증하면 401로 거부되는 것도 확인했습니다.
server.json의 주요 항목은 다음과 같습니다(실물에는 이 외 아이콘 지정도 포함되어 있습니다).
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "net.kitbench/kitbench",
...
}
원격 서버이므로 packages는 없고, remotes에 streamable-http의 URL만 적으면 됩니다. 등록된 내용은 Registry API(https://registry.modelcontextprotocol.io/v0/servers?search=kitbench)에서 다시 읽어올 수 있습니다.
요약
- 답이 하나로 결정되는 계산(영업일・간지월・글자 수 등)은 AI에게 생각하게 하지 않고 MCP의 툴로 호출시킴 - 상태도 중간 과정도 필요 없다면, POST만・JSON을 하나 반환・SSE 없음・세션 없음의 Streamable HTTP로 충분히 만들 수 있음 - 이식한 코드는, 원본 코드를 그대로 작동시켜 기대값을 만들고, 대량으로 비교함. 일부러 고장 내서 테스트가 찾아내는지도 확인해 보는 것이 좋음 -
MCP-Protocol-Version
는, 사양상 미지원 버전에 400을 반환합니다. 지원하는 버전 목록은 조속히 업데이트하고, 실제 환경에서 연결하여 확인해 주세요.
이 MCP 서버는 웹 툴 모음인 'KitBench'의 계산 도구를 기반으로 합니다.
Discussion