나만의 MCP 서버를 구축하는 방법
요약
Air Pipe를 사용하여 실제 서비스 가능한 수준의 MCP(Model Context Protocol) 서버를 구축하는 엔드 투 엔드 가이드를 제공합니다. 로컬 환경을 넘어 Postgres 데이터베이스와 클라우드 환경을 활용해 Claude Desktop, Cursor 등에서 즉시 사용 가능한 서버를 만드는 과정을 다룹니다.
핵심 포인트
- 단순 튜토리얼을 넘어 실제 고객에게 제공 가능한 MCP 서버 구축 방법 제시
- Neon 등 호스팅된 Postgres 데이터베이스를 활용한 클라우드 기반 설정
- Air Pipe를 이용한 환경 변수 및 데이터베이스 연결 관리
- Claude Desktop, Claude Code, Cursor 등 다양한 MCP 클라이언트와 연동 가능
대부분의 MCP 튜토리얼은 Node 프로젝트를 제공합니다. SDK를 설치하고, 도구 핸들러 (tool handler)를 작성하고, stdio를 연결하면, 결국 당신의 자격 증명을 사용하여 당신과 함께 당신의 노트북에서 정확히 한 명의 사용자를 위해 실행되는 무언가를 얻게 됩니다.
데모용으로는 괜찮습니다. 하지만 고객에게 줄 수 있는 것은 아닙니다.
여기 또 다른 엔드 투 엔드 (end to end) 방식이 있습니다: 데이터베이스 하나, 설정 파일 하나, 토큰 하나, 그리고 Claude에 붙여넣을 URL 하나면 됩니다. 아래의 모든 단계는 실제 패키지를 대상으로 하는 실제 명령입니다. 생략된 것이나 연습 문제로 남겨둔 것은 없습니다.
소요 시간: 약 15분. 필요한 것: Air Pipe 계정 (무료 티어로 충분함), Postgres 데이터베이스, 그리고 MCP 클라이언트 — Claude Desktop, Claude Code, Cursor, MCP를 지원하는 무엇이든 가능합니다.
1단계 — Postgres 데이터베이스 확보하기
이미 가지고 있다면 건너뛰세요. 없다면 다음 중 어떤 것이든 작동하며 모두 사용 가능한 무료 티어를 제공합니다:
| 제공업체 | 제공 내용 |
|---|---|
| Neon | Serverless Postgres, 무료 티어, 대시보드 내 연결 문자열 (connection string) |
| ... |
필요한 것은 하나의 연결 문자열 (connection string)입니다:
postgresql://user:password@host:5432/dbname
로컬 Postgres를 사용하여 따라 할 수 있지만, 관리형 Air Pipe 인스턴스는 localhost에 도달할 수 없습니다. 따라서 Claude Desktop에서 도구를 라이브로 사용하고 싶다면, 호스팅된 데이터베이스를 사용하거나 로컬 데이터베이스 옆에 Air Pipe 바이너리를 셀프 호스팅(self-host)하세요.
SSL에 대하여: 대부분의 호스팅 제공업체는 SSL을 요구합니다. 첫 번째 쿼리가 SSL is required와 함께 실패한다면, 연결 문자열 끝에 ?sslmode=require를 추가하세요. Neon은 이것이 필요하며, Supabase는 제공하는 문자열에 이를 포함하고 있습니다.
2단계 — 스키마 (schema) 생성하기
세 개의 테이블입니다. 그중 하나만이 당신의 데이터입니다:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 테넌트 (tenant)는 당신의 고객 중 한 명입니다. 혼자 사용하는 동안에는 완전히 무시하세요;
...
실행하세요:
psql "$DATABASE_URL" -f schema.sql
pgcrypto는 Postgres 12 및 이전 버전에서 gen_random_uuid()를 사용하는 데에만 필요합니다. 13 버전부터는 내장되어 있으며, IF NOT EXISTS 덕분에 어느 쪽이든 해당 라인은 무해합니다.
무언가 볼 수 있도록 테넌트(tenant)와 몇 개의 행(rows)을 시드(seed)합니다:
INSERT INTO mcp_tenants (id, name)
VALUES ('11111111-1111-1111-1111-111111111111', 'Acme Inc');
...
Step 3 — 두 개의 변수 설정하기
Air Pipe 대시보드 내의 환경 관리 변수(managed variables) 아래에서(또는 셀프 호스팅 중이라면 ap_var로) 설정합니다:
| 이름 | 값 |
|---|---|
DATABASE_URL | 1단계에서 얻은 연결 문자열 (connection string) |
SOLO_SECRET | 32자 이상의 무작위 문자열 |
직접 타이핑하기보다 비밀키(secret)를 생성하세요. 이것이 인터넷과 당신의 데이터베이스 사이를 가로막는 유일한 방어선입니다:
openssl rand -base64 48
두 변수 모두 설정(config)에서 a|ap_var::NAME|로 참조되므로, 커밋(commit)하는 파일에는 절대 나타나지 않습니다.
Step 4 — 설정(config) 작성하기
전체 내용입니다. 파일 하나, 도구 두 개.
name: McpTasks
description: MCP tools over Postgres, guarded by a single shared HS256 token.
...
짚고 넘어갈 만한 다섯 가지 사항입니다:
mcp_servers는 서버이며, mcp: 블록은 도구(tools)입니다. 상단의 선언은 클라이언트와 레지스트리(registry)가 도구를 실행하기 전에 보는 정보입니다 — 이에 대한 자세한 내용은 8단계 이후에 설명합니다. 이를 삭제해도 모든 기능은 여전히 작동하지만, 익명으로 작동하게 됩니다.
mcp: 블록은 이것을 도구로 만드는 유일한 요소입니다. 이를 삭제하면 일반적인 HTTP 라우트(route)가 됩니다. 이를 유지하면 동일한 인증, 동일한 쿼리, 동일한 추적(trace)을 가진 하나의 정의로 두 가지 기능을 모두 가질 수 있습니다.
인증(Auth)은 MCP 전용이 아닙니다. Air Pipe는 클라이언트의 Authorization: Bearer 토큰을 가져와 인터페이스에 airpipe-jwt 헤더로 전달하며, HTTP 요청이 수행하는 것과 동일한 동작을 실행합니다. MCP 도구를 보호하는 것은 정확히 라우트를 보호하는 것과 같습니다. 두 개를 배울 필요 없이, 하나만 배우면 됩니다.
CheckBody는 AI가 보는 것입니다. MCP inputSchema (입력 스키마)는 이러한 단언 테스트 (assert tests)로부터 생성됩니다. 이것이 각 테스트가 description: (설명)을 포함하는 이유입니다. 모델이 설명을 읽고 도구를 선택하기 때문에, 당신이 아닌 다른 독자를 위해 이를 작성하세요. is_not_null: false는 항상 통과하는 술어 (predicate)입니다. 이는 해당 필드를 요구하지 않으면서 선택 사항 (optional)으로 선언합니다. 그리고 오직 CheckBody만이 a|body|를 읽기 때문에, 해당 토큰은 도구의 스키마로 절대 유출되지 않습니다.
매개변수 (Parameters)는 보간 (interpolated)되는 것이 아니라 바인딩 (bound)됩니다. params: 리스트와 함께 $1, $2를 사용하므로, '); DROP TABLE mcp_tasks; --라는 제목의 작업은 단순한 작업 제목일 뿐입니다.
Step 5 — 배포 (Deploy)
빌드할 것도, 호스팅할 것도 없습니다.
관리형 Air Pipe를 사용하는 경우, 파일을 대시보드 에디터에 붙여넣고 배포 (deploy)를 누르세요. 그러면 입력 과정에서 유효성 검사가 수행됩니다. 본인의 AI 클라이언트에서 Air Pipe MCP 도구를 사용하는 경우, 채팅창에서 "이 설정을 검증하고 배포해줘"라고 하면 동일하게 작동하며, (아래의) 팩을 설치하면 이 과정 없이도 가능합니다.
셀프 호스팅 (Self-hosting)은 명령어 하나로 가능합니다. 바이너리를 파일이 있는 디렉터리로 지정하세요:
airpipe server --config-dir . --api-key <your-key>
기본적으로 4111 포트에서 서비스되므로, 다음 단계의 URL은 http://localhost:4111/…가 됩니다. airpipe login을 한 번 실행하면 --api-key를 생략할 수 있습니다.
Step 6 — 토큰 발행 (Mint a token)
jwt.io에서 한 번만 다음 설정을 수행하세요: 알고리즘은 HS256, secret (비밀키) = 당신의 SOLO_SECRET, payload (페이로드):
{ "sub": "me", "exp": 1798761600 }
토큰을 복사하세요. SOLO_SECRET을 교체(rotating)하면 해당 토큰은 무효화됩니다.
커맨드 라인 (command line) 사용을 권장합니다:
python3 - <<'PY'
import base64, hmac, hashlib, json, os
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=')
...
Step 7 — 클라이언트를 접하기 전에 검증하기
MCP 클라이언트를 통해 디버깅하는 것은 매우 고통스럽습니다. 실패할 경우 단순히 "도구가 작동하지 않았습니다"라고 표시되기 때문입니다. 먼저 curl로 확인하세요. MCP는 HTTP 기반의 JSON-RPC이므로 직접 제어할 수 있습니다:
BASE=https://your-airpipe-host/<org>/<env> # 셀프 호스팅 시: /<org>/<env> 제외
TOKEN=<step 6에서 생성한 토큰>
...
만약 tools/list가 당신의 두 가지 도구(tools)를 반환하고 tools/call이 행(row)을 반환한다면, 완료된 것입니다. 이 이후의 모든 과정은 클라이언트 설정(client configuration)입니다.
흔히 발생하는 두 가지 실패 사례를 언급하겠습니다:
- 401
Invalid or missing token— 서명에 사용된 비밀키(secret)가SOLO_SECRET과 일치하지 않거나,exp(만료 시간)가 과거인 경우입니다. jwt.io에서 토큰을 디코딩하여 만료 시간을 먼저 확인하세요. 보통 이 문제입니다. - 쿼리(query) 작업 시 데이터베이스 오류 — Air Pipe 인스턴스에서 연결 문자열(connection string)에 접근할 수 없는 경우입니다. 보통
localhost가 원인이거나, 다른 경우에는 SSL 문제입니다.
Step 8 — Claude를 서버에 연결하기
{
"mcpServers": {
"my-tasks": {
...
Claude Desktop은 macOS의 경우 ~/Library/Application Support/Claude/claude_desktop_config.json에, Windows의 경우 %APPDATA%\Claude\claude_desktop_config.json에 이 설정을 저장합니다. Claude Code의 경우:
claude mcp add --transport http my-tasks https://your-airpipe-host/<org>/<env>/mcp --header "Authorization: Bearer <token>"를 사용합니다.
클라이언트를 재시작하세요. _"내 작업 목록에 무엇이 있지?"_라고 물어보면 클라이언트가 당신의 데이터베이스에 쿼리를 보냅니다.
또한, 동일한 파일에서 별도의 작업 없이도 다음을 얻을 수 있습니다: MCP를 지원하지 않는 클라이언트를 위한 HTTP 엔드포인트(endpoint), OpenAPI 문서, Prometheus 메트릭(metrics), 그리고 어떤 작업이 실행되었고 쿼리에 시간이 얼마나 걸렸는지 보여주는 모든 도구 호출(tool call)에 대한 OpenTelemetry 트레이스(trace)입니다. 마지막 항목은 들리는 것보다 더 중요합니다. 모델이 도구를 호출했는데 혼란스러운 답변을 받는 경우, 트레이스를 통해 도구가 잘못된 것인지 아니면 모델이 잘못된 것인지 확인할 수 있기 때문입니다.
도구뿐만 아니라 서버에 이름도 부여하세요
모든 클라이언트는 목록을 나열하기 전에 initialize를 호출하며, 그 응답을 통해 서버는 자신이 누구인지 밝힙니다. 이 과정을 건너뛰면 당신의 서버는 내장된 이름과 설명 없이 자신을 소개하게 됩니다. 이는 도구 설명의 벽 위에 덩그러니 이름만 붙어 있는 목록이 됩니다. 설정 상단의 mcp_servers가 바로 이 문제를 해결합니다:
mcp_servers:
tasks:
title: Tasks # -> serverInfo.title, 클라이언트 UI에 표시되는 이름
...
instructions는 보기보다 중요합니다. MCP 레지스트리(MCP registries) — mcp.so, Glama, Smithery, PulseMCP — 는 해당 필드에서 원격 서버의 목록 설명을 직접 읽어옵니다. 이를 작성할 수 있는 다른 장소는 없으므로, 목록에 없는 설명은 단순히 어딘가에 비어 있는 필드가 아니라, 아무도 클릭하지 않는 목록이 된다는 것을 의미합니다.
도구(tools)를 확인했던 것과 동일한 방식으로 확인하세요:
curl -sX POST $BASE/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
...
서버를 선언하는 것과 서버에 도구를 기여하는 것은 의도적으로 분리되어 있습니다. MCP 서버는 도구들의 이름이 지정된 그룹이지, 하나의 설정(config)에 종속된 속성이 아닙니다. 여러분의 어떠한 설정에 있는 도구라도 ID를 통해 서버에 합류하므로, 하나의 정체성(identity)이 여러 파일에 흩어져 있는 도구들을 아우를 수 있습니다. 이는 또한 선언문이 자체 설정(interfaces: {})에 독립적으로 존재할 수 있으며, 다음에 어떤 도구 파일의 이름을 바꾸더라도 유지될 수 있음을 의미합니다.
ID는 경로 세그먼트(route segment)이므로, 두 번째 선언은 동일한 배포(deployment)로부터의 두 번째 엔드포인트가 됩니다. 예를 들어, 하나의 공개 서버와 하나의 내부 서버를 다음과 같이 구성할 수 있습니다:
mcp_servers:
tasks: # /mcp에서 서비스됨
title: Tasks
...
mcp:
enabled: true
tool_name: purge_tasks
...
ID는 a-z, 0-9 또는 -로 구성된 1~64자여야 하며, 단 하나의 서버만 기본(default) 서버가 될 수 있습니다. 또한 서버 이름을 지정하지 않은 도구는 잘못된 서버에 등록되는 것이 아니라 어떠한 서버에도 게시되지 않습니다. 엔진 버전 1.38.0 이상이 필요합니다.
대부분의 MCP 서버가 가진 허점
무엇을 만들든 상관없이 알아둘 가치가 있는 사실이 있습니다: tools/call은 코드를 실행하지만, tools/list는 실행하지 않습니다.
도구 목록을 나열하는 것은 메타데이터(이름, 설명, 입력 스키마)를 반환합니다. 핸들러(handlers) 내부에 설정한 어떤 인증(auth)도 탐색(discovery) 과정에서는 실행되지 않습니다. 따라서 호출이 엄격히 제한된 서버라 할지라도, URL을 아는 사람이라면 누구나 노출된 모든 도구와 그 전체 스키마를 열거할 수 있습니다. 그들은 아무것도 호출할 수는 없지만, 지도를 읽을 수는 있습니다.
개인용 서버라면 괜찮습니다. 하지만 고객에게 제공하는 엔드포인트(endpoint)의 경우, 해당 카탈로그는 종종 민감한 부분이 됩니다. 즉, 도구(tool)의 이름 자체가 제품에 대한 설명이기 때문입니다.
클라이언트가 도구를 나열할 때 토큰 확인을 다시 수행하는 인터페이스를 가리키도록, 도구당 한 줄씩 추가하여 이를 차단하세요:
mcp:
enabled: true
tool_name: list_tasks
...
그리고 게이트(gate) 자체는 도구가 아닌 일반적인 인터페이스입니다:
authorize-discovery:
output: http
method: POST
...
이제 인증되지 않은 tools/list 호출은 이름조차 포함되지 않은 {"result":{"tools":[]}}를 반환합니다.
response_on_success: { http_code: 200 }설정이 필수입니다. 게이트는 명시적인 2xx 응답이 아닌 모든 상황에 대해 '실패 시 차단(fail-closed)' 방식으로 동작합니다. 모든 작업이 성공하는 인터페이스의 경우 상태 코드(status code)가 설정되지 않은 상태로 남게 되는데, 이는
다른 테넌트(tenant)의 행이 일치하지 않습니다. 테넌트 간 접근(Cross-tenant access)은 단순히 금지된 것이 아니라 구조적으로 불가능합니다. 즉, WHERE 절을 누락하여 고객의 데이터가 유출되는 코드 경로(code path) 자체가 존재하지 않는데, 이는 필터(filter) 자체가 곧 쿼리(query)이기 때문입니다. 하나의 엔드포인트(endpoint)에서 모든 고객이 각자의 행만을 볼 수 있습니다.
상태가 없는 JWT(stateless JWTs)가 단독으로 할 수 없는 권한 취소(Revocation)
서명 확인(signature check)만으로는 취소된 토큰과 유효한 토큰을 구분할 수 없습니다. 이를 위해 mcp_tokens 테이블이 존재합니다. 모든 도구(tool)는 이 테이블을 통해 토큰의 jti를 다시 확인합니다:
- name: CheckTokenActive
run_when_succeeded:
actions: [ValidateJwt]
http_code_on_error: 401
database: main
hide_data_on_success: true
query: |
SELECT (
$1::uuid IS NULL OR EXISTS (
SELECT 1 FROM mcp_tokens
WHERE jti = $1::uuid AND revoked
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기