셀프 호스팅 MCP: PHP로 Model Context Protocol 서버 구축하기
요약
TypeScript나 Python 외에 PHP를 사용하여 Model Context Protocol(MCP) 서버를 구축하는 방법을 다룹니다. MCP의 핵심인 JSON-RPC 2.0 구조와 Tools, Resources, Prompts의 차이점을 설명합니다.
핵심 포인트
- MCP는 특정 언어에 종속되지 않는 JSON-RPC 2.0 기반의 와이어 프로토콜임
- Tools는 모델이 제어하는 액션, Resources는 애플리케이션이 제어하는 데이터임
- PHP에서도 표준 입출력을 통해 MCP 서버 구현이 가능함
- stdio 전송 방식을 통해 클라이언트와 서브프로세스로 통신할 수 있음
여러분이 찾을 수 있는 대부분의 Model Context Protocol 튜토리얼은 TypeScript나 Python으로 작성되어 있습니다. 왜냐하면 그 언어들이 공식 SDK를 제공하는 언어들이기 때문입니다. 이로 인해 수년간의 비즈니스 데이터를 보유한 PHP 애플리케이션을 유지 관리하는 적지 않은 사람들이, 이 프로토콜을 우리도 사용할 수 있는지 의문을 갖게 됩니다.
사용할 수 있습니다. MCP는 라이브러리가 아니라 와이어 프로토콜 (wire protocol)입니다. 여러분의 언어가 표준 입력 (standard input)에서 한 줄을 읽고 JSON을 다시 쓸 수 있다면, 해당 언어로 서버를 구현할 수 있습니다. 이 포스트에서는 프로토콜이 실제로 여러분에게 요구하는 것이 무엇인지, PHP 구현의 개요는 어떤 모습인지, 그리고 — 제가 과소평가했던 부분인데 — 내부 시스템을 모델에 노출할 때 어떤 변화가 생기는지에 대해 살펴봅니다.
MCP의 실제 정체
Model Context Protocol은 AI 어시스턴트를 외부 시스템, 즉 여러분의 데이터, 도구, API에 연결하기 위한 개방형 표준입니다. 이 프로토콜이 해결하는 문제는 조합론적 (combinatorial)입니다. 표준이 존재하기 전에는 모든 어시스턴트가 모든 데이터 소스와 개별적인 통합 (bespoke integration)을 수행해야 했습니다. MCP는 하나의 인터페이스를 정의하여, 규격을 준수하는 어떤 클라이언트라도 규격을 준수하는 어떤 서버와도 통신할 수 있게 합니다.
그 내부를 들여다보면, 이는 JSON-RPC 2.0입니다. 요청 (requests)은 jsonrpc 버전, method, params 객체, 그리고 id를 포함하며, 응답 (responses)은 일치하는 id와 result 또는 error 중 하나를 포함합니다. 알림 (Notifications)은 id가 없는 요청이며 응답을 기대하지 않습니다. 이전에 JSON-RPC 서비스를 구현해 본 적이 있다면, 전송 (transport) 과정의 80%는 이미 알고 있는 셈입니다.
서버는 세 가지 종류의 프리미티브 (primitive)를 노출합니다:
- Tools (도구) — 모델이 호출할 수 있는 액션 (actions)입니다. 각 도구는 이름, 설명, 그리고 입력을 기술하는 JSON Schema를 가집니다. 이는 데이터베이스 쿼리, 레코드 생성, 요청 전송과 같이 무언가를 수행하는 프리미티브 (primitive)입니다. 모델은 이 도구들을 언제 호출할지 스스로 결정합니다.
- Resources (리소스) — 모델이 읽을 수 있는 데이터로, URI를 통해 주소가 지정됩니다. 파일, 레코드, 생성된 문서 등이 이에 해당합니다. 이는 액션이 아닌 컨텍스트 (context)를 위한 것이며, 일반적으로 클라이언트가 무엇을 가져올지 결정합니다.
- Prompts (프롬프트) — 사용자가 의도적으로 호출할 수 있는 재사용 가능한 프롬프트 템플릿 (prompt templates)이며, 종종 클라이언트의 UI에서 슬래시 명령어나 메뉴 항목으로 나타납니다.
도구 (tools)와 리소스 (resources) 사이의 구분은 처음 생각하는 것보다 더 중요합니다. 대략적인 규칙은 다음과 같습니다: 도구는 모델이 제어하고, 리소스는 애플리케이션이 제어합니다. 만약 모델이 무언가를 가져올지 여부를 결정해야 한다면 도구로 만드세요. 만약 사용자나 호스트 애플리케이션이 결정한다면 리소스로 만드세요.
두 가지 전송 방식 (transports)
MCP는 두 가지 표준 전송 방식 (transports)을 정의하며, 적절한 방식을 선택하는 것이 첫 번째 아키텍처 결정 사항입니다.
stdio. 클라이언트는 귀하의 서버를 서브프로세스 (subprocess)로 실행하고 표준 입력 (standard input) 및 표준 출력 (standard output)을 통해 통신합니다. 이때 메시지는 줄바꿈으로 구분된 JSON (newline-delimited JSON) 형태이며, 한 줄에 하나의 메시지가 전송됩니다. 이는 가능한 가장 단순한 설정입니다. 포트, HTTP 서버, 인증 계층이 필요 없는데, 왜냐하면 귀하의 프로세스와 통신할 수 있는 유일한 대상은 프로세스를 생성한 부모 프로세스뿐이기 때문입니다. 클라이언트와 동일한 머신에서 실행되는 모든 작업에 적합한 선택입니다.
stdio를 사용할 때 PHP에서 어기기 쉬운 두 가지 규칙이 있습니다:
- stdout (표준 출력)에는 프로토콜 메시지 외에 아무것도 작성하지 마세요. 실수로 작성한
echo, 디버깅 중에 남겨둔var_dump, 또는 stdout으로 출력된 PHP 경고 (warning)는 메시지 스트림을 손상시켜 클라이언트가 이를 파싱하는 데 실패하게 만듭니다. 대신 진단 정보는 **stderr (표준 에러)**로 보내세요. 클라이언트는 일반적으로 stderr를 로그로 전달합니다. - 출력 버퍼링 (output buffering)을 끄고 매 쓰기 작업 후에 플러시 (flush) 하세요. 그렇지 않으면 클라이언트가 기다리는 동안 응답이 버퍼에 머물게 됩니다.
스트리밍 가능한 HTTP (Streamable HTTP). 서버는 일반적인 HTTP 엔드포인트 (endpoint)로 동작합니다. 클라이언트는 서버에 JSON-RPC 메시지를 POST로 전송하며, 서버는 단일 JSON 응답을 보내거나, 하나의 요청에 대해 여러 메시지를 푸시해야 하는 경우 서버 전송 이벤트 (Server-Sent Events, SSE) 스트림으로 응답합니다. 이는 사용자의 로컬 머신이 아닌 다른 곳에서 실행되는 서버를 위한 전송 방식 (transport)입니다. 대부분의 PHP 환경에서는 이미 익숙한 배포 모델이기 때문에 이 방식이 매우 흥미로운 사례가 될 것입니다.
(이전 사양의 초기 버전에는 기존의 HTTP+SSE 전송 방식이 존재합니다. 새로운 작업은 스트리밍 가능한 HTTP를 대상으로 해야 합니다.)
핸드셰이크 (The handshake)
어떤 전송 방식을 선택하든 대화는 동일한 방식으로 시작됩니다. 클라이언트는 자신이 사용하는 프로토콜 버전과 지원하는 기능 (capabilities)을 포함하여 initialize를 보냅니다. 서버는 자신의 프로토콜 버전, 기능, 그리고 이름과 버전을 담아 응답합니다. 그 후 클라이언트가 initialized 알림을 보내면 정상적인 동작이 시작됩니다.
기능 (Capabilities)은 양측이 협상하는 방법입니다. 만약 서버가 리소스 (resources)를 구현하지 않았다면, 리소스 기능을 광고하지 않아야 하며, 규칙을 잘 따르는 클라이언트는 resources/list를 호출하지 않을 것입니다. 구현하지 않은 기능을 광고하지 마세요.
핸드셰이크 이후에 중요한 메서드 (methods)들은 예측 가능합니다:
| 메서드 (Method) | 역할 |
|---|---|
tools/list | 설명과 입력 스키마 (input schemas)와 함께 노출된 도구들을 반환 |
| ... |
최소한의 구성이면서도 진정으로 유용한 서버는 initialize에 tools/list와 tools/call을 더한 형태입니다. 그 외의 모든 것은 선택 사항입니다.
PHP 측의 모습
전송 방식과 무관한 구조적 형태는 다음과 같습니다:
JSON-RPC 메시지 읽기
→ `method`에 따라 디스패치 (dispatch)
→ 결과 (또는 에러) 생성
...
stdio의 경우, 이는 fgets(STDIN) 호출, json_decode, 메서드 이름에 따른 match 처리, 그리고 fwrite(STDOUT, json_encode($response) . "\n")를 반복하는 루프입니다. Streamable HTTP의 경우, 요청 본문(request body)을 디코딩하고 인코딩된 응답을 반환하는 단일 엔드포인트(endpoint)로 구성됩니다. 중간의 디스패치(dispatch) 계층은 동일하며, 읽기(read)와 쓰기(write) 끝단만 달라집니다. 처음부터 이런 방식으로 작성하면 하나의 코드베이스로 두 방식 모두를 지원할 수 있습니다.
시작하기 전에 알아두면 좋은 PHP 특화 사항 세 가지가 있습니다.
도구 스키마 (Tool schemas). 모든 도구는 입력값에 대한 JSON 스키마 (JSON Schema)가 필요합니다. 이를 중첩된 배열(nested arrays)로 직접 작성하는 것은 금방 지루해집니다. 이미 유지 관리하고 있는 것 — 검증 규칙 세트(validation ruleset), DTO, 리플렉션(reflection)을 통해 읽어온 타입이 지정된 생성자 매개변수 세트 — 으로부터 스키마를 유도하면 스키마와 실제 구현이 서로 어긋나는 것을 방지할 수 있습니다. 스키마 드리프트 (Schema drift)는 "모델이 도구를 계속 잘못 호출하는" 가장 흔한 원인입니다.
에러 핸들링 (Error handling). 두 가지 종류의 실패를 구분해야 합니다. 잘못된 형식의 요청이나 알 수 없는 메서드는 **프로토콜 에러 (protocol error)**입니다. 이 경우 JSON-RPC error 객체를 반환하세요. 실행되었으나 실패한 도구 — 레코드를 찾을 수 없음, 입력값이 검증에서 거부됨 — 는 **도구 에러 (tool error)**입니다. 이 경우 에러 플래그와 사람이 읽을 수 있는 메시지가 설정된 일반적인 결과를 반환하세요. 이 구분이 중요한 이유는 두 번째 종류의 에러는 모델로 다시 전달되어, 모델이 메시지를 읽고 조정할 수 있기 때문입니다. 프로토콜 에러는 단지 무언가 고장 났음을 알려줄 뿐입니다. 실패 원인이 모델이 복구할 수 있는 성격의 것이라면, PHP 예외(exception)를 두 번째 종류(도구 에러)로 변환하세요.
장시간 실행되는 작업 (Long-running work). PHP의 프로세스당 요청(request-per-process) 모델은 stdio(세션이 유지되는 동안 프로세스가 생존함)에는 적합하지만, 도구가 몇 분씩 걸리는 HTTP 환경에서는 다소 어색할 수 있습니다. 도구 호출을 짧게 유지하세요. 만약 도구가 느린 작업을 시작한다면, 즉시 작업 식별자(job identifier)를 반환하고 상태를 보고하는 두 번째 도구를 노출하세요. 모델은 이러한 패턴을 잘 처리하지만, 타임아웃(timeout)이 발생하는 요청은 제대로 처리하지 못합니다.
Claude와 연결하기
서버가 실행되면, 서버에 도달하는 세 가지 광범위한 방법이 있습니다.
로컬, stdio를 통한 연결. Claude Desktop, Claude Code 및 다양한 IDE 통합(IDE integrations)과 같은 데스크톱 및 CLI 클라이언트는 실행할 명령과 인자(php 및 서버 스크립트 경로)를 지정함으로써 서버를 등록할 수 있게 해주며, 클라이언트가 해당 서브프로세스(subprocess)를 대신 관리합니다. 이는 "tools/list에 응답한다"는 상태에서 "실제로 사용 중이다"라는 상태로 넘어가는 가장 빠른 방법입니다.
원격, HTTP를 통한 연결. URL에 배포된 스트리밍 가능한(Streamable) HTTP 서버는 원격 연결을 지원하는 클라이언트에 등록할 수 있습니다. Claude API 또한 MCP 커넥터(connector)를 제공합니다. 요청 시 서버의 URL을 선언하면, API가 서버 측에서 연결을 수행하므로 클라이언트 루프(client loop)를 직접 작성하지 않고도 모델이 도구(tools)를 호출할 수 있습니다. 주의할 점은, 호스팅된 MCP 서버는 일반적으로 서비스 자체의 네이티브 API 키가 아닌 OAuth 베어러 토큰(OAuth bearer tokens)으로 인증한다는 것입니다. 이 둘은 서로 다른 인증 시스템이며, 후자가 작동할 것이라고 가정하는 것은 초기에 흔히 발생하는 실수입니다.
자체 코드에서 연결. 기존 클라이언트를 사용하는 대신 에이전트(agent)를 직접 구축하고 있다면, 대부분의 AI SDK는 MCP 도구 정의를 자체적인 도구 형식으로 변환할 수 있습니다. 따라서 MCP 서버는 사용자가 제어하는 루프를 위한 도구 공급원이 됩니다.
어떤 경로를 선택하든, 먼저 stdio를 통해 디버깅하세요. 실패 모드가 더 단순합니다. TLS, 인증, 프록시(proxy), CORS 문제가 없습니다. 프로토콜을 올바르게 구현한 다음, HTTP로 옮기십시오.
내가 과소평가했던 부분: 노출(exposure)
MCP 서버를 기존 시스템 앞에 배치할 때 변화하는 지점은 바로 이것입니다. 노출하는 모든 도구는 외부 세계로부터 읽어온 텍스트에 의해 부분적으로 제어되는 모델에게 부여된 권한(capability)입니다. 만약 모델이 당신의 도구를 호출하도록 설득될 수 있다면, 당신의 도구는 실행됩니다.
실질적인 결과는 다음과 같습니다:
도구의 범위를 좁게 설정하세요. 임의의 SQL을 받아들이는 일반적인 run_query 도구는 구축하기 가장 편리하지만 배포하기 가장 위험한 것입니다. 대신, 타입이 지정된 매개변수를 가진 구체적인 도구들 — 예를 들어, find_customer_by_email, list_orders_in_range와 같은 것 — 을 선호하고, 모든 인수는 마치 익명의 HTTP 요청에서 온 것처럼 서버 측에서 검증해야 합니다. 실제로도 그랬습니다.
읽기와 쓰기는 다른 위험 등급입니다. 읽기 전용 도구는 정보 노출(disclosure) 위험을 가집니다. 쓰기 도구는 '실제로 이런 일이 일어났다'라는 위험을 가집니다. 이 둘을 분리하고, 파괴적이거나 되돌릴 수 없는 작업은 모델이 조심할 것이라고 믿기보다는 호스트 애플리케이션에서 명시적인 확인 절차를 거치도록 해야 합니다.
설명(description) 자체가 보안 표면적입니다. 도구 설명은 모델이 읽는 지침입니다. 모호한 설명은 모델이 의도하지 않은 상황에서도 도구를 호출하도록 유도합니다. 도구가 언제 사용되어야 하는지에 대해 구체적으로 명시하는 것은 단순히 무엇을 하는지 뿐만 아니라 정확성과 안전성을 측정 가능하게 향상시킵니다.
노출하고 싶지 않은 정보는 새어 나가지 않게 하세요. 전체 레코드가 아닌, 작업에 필요한 필드만 반환해야 합니다. 고객에게 보여줄 답변에 포함되어서는 안 되는 내부 메모, 원가, 또는 개인 전화번호 역시 도구 결과에 포함되어서는 안 됩니다. 프롬프트에서가 아니라 서버에서 필터링하세요.
모든 호출을 기록하세요. 도구 이름, 인자, 호출자, 결과 등을 모두 기록해야 합니다. 누군가
조직 내에서 매주 반복되는 질문에 답할 수 있는 세 가지 읽기 전용 (read-only) 도구로 시작하세요. 이를 stdio를 통해 한 명에게 배포해 보세요. 그들이 실제로 무엇을 요청하는지 확인하십시오. 그것은 당신이 사전에 설계한 그 어떤 것보다 훨씬 더 나은 명세 (spec)가 될 것입니다.
만약 공식 SDK가 없는 언어로 MCP 서버를 구축해 보셨다면, 무엇이 당신을 어렵게 만들었는지 궁금합니다. 전송 (transport) 방식이었나요, 스키마 (schemas)였나요, 아니면 스코핑 (scoping) 문제였나요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기