(2/2) MCP 서버를 원격 HTTP + 인증으로 띄우기 — 여러 사람이 같이 쓰기
📚 MCP 서버 직접 만들기 시리즈 (전체 2편)
- 로컬·Claude 전용으로 만들기 — Python(FastMCP) stdio
- 원격 HTTP + 인증으로 띄워 여러 사람이 같이 쓰기 ← 지금 글
Summary
1편 에서는 포트를 하나도 안 열고 내 컴퓨터의 Claude 만 붙는 로컬·stdio 서버를 만들었어요. 이번 2편은 그 서버를 원격(Streamable HTTP) 으로 띄워서 여러 사람·여러 머신이 같이 쓰게 만드는 이야기입니다.
포트를 여는 순간 그림이 통째로 바뀝니다. 누구나 닿을 수 있게 되니 인증이 선택이 아니라 필수 가 되고, TLS·Origin 검증 같은 운영 항목이 줄줄이 따라붙어요. 이 글은 트랜스포트를 바꾸는 한 줄부터, 인증 두 갈래(Bearer 토큰 / OAuth 2.1), 클라이언트 등록, 그리고 실서비스로 띄울 때 빠뜨리면 안 되는 것들까지 차례로 정리합니다.
💡 이 글에서 다루는 것
- stdio → Streamable HTTP, 무엇이 달라지나
- 트랜스포트만 바꿔서 로컬에서 HTTP 로 띄워보기
- 포트를 여는 순간 바뀌는 위협 모델 — 왜 인증이 필수인가
- 인증 길 1 — Bearer 토큰 + ASGI 미들웨어 (실용 추천)
- 인증 길 2 — OAuth 2.1 TokenVerifier (스펙 정석)
- Claude Code / Claude Desktop 에서 원격 서버 등록
- 운영 필수 — TLS · Origin 검증 · CORS · stateless
- Caddy 리버스 프록시 + systemd 배포 예시
- 원격 방식의 장단점 과 보안 체크리스트
1. 로컬에서 원격으로 — 무엇이 달라지나
1편의 로컬 서버와 견주면 바뀌는 축이 분명해요.
| 축 | 로컬 (stdio) | 원격 (Streamable HTTP) |
|---|---|---|
| 통신 | 호스트가 프로세스를 띄워 stdin/stdout 파이프 | 서버가 HTTP 엔드포인트(/mcp)를 열고 네트워크 접속 |
| 노출 | 그 머신, 그 사용자만 | 포트가 닿는 모든 곳 |
| 수명 | 호스트가 띄우고 종료 | 서버를 우리가 띄워두고 관리 (데몬) |
| 인증 | 사실상 불필요 (외부 접근 불가) | 필수 (Bearer / OAuth) |
| 부가 운영 | 거의 없음 | TLS · Origin 검증 · CORS · 모니터링 |
핵심은 “수명 관리가 호스트에서 우리에게 넘어온다” 는 점이에요. 로컬에선 Claude 가 켜질 때 서버를 띄워줬지만, 원격에선 우리가 서버를 계속 떠 있는 데몬 으로 직접 운영해야 합니다.
2. 1단계 — 트랜스포트만 바꿔서 HTTP 로 띄우기
놀랍게도 코드 변화는 거의 없어요. 1편의 서버에서 mcp.run() 의 트랜스포트만 바꾸면 됩니다.
# server.py
from mcp.server.fastmcp import FastMCP
# host/port 를 지정. 0.0.0.0 은 모든 인터페이스에 바인딩(외부 접속 허용).
mcp = FastMCP("remote-demo", host="127.0.0.1", port=8000)
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 정수를 더해서 돌려준다."""
return a + b
if __name__ == "__main__":
# 여기만 바뀜: stdio → streamable-http
mcp.run(transport="streamable-http")
도구 함수 자체는 1편과 똑같은 평범한 파이썬 함수예요.
print(add(10, 32))
42
띄우면 http://127.0.0.1:8000/mcp 에 MCP 엔드포인트가 열립니다.
python server.py
# 또는 uv run mcp run server.py --transport streamable-http
⚠️ 지금은
host="127.0.0.1"이라 내 컴퓨터 안에서만 닿아요. 인증을 붙이기 전에는 절대0.0.0.0으로 외부에 열지 마세요. 인증 없는 MCP 서버를 인터넷에 노출하면 누구나 우리 도구를 호출 할 수 있습니다.
💡 stateless 모드 — 기본은 세션(상태)을 유지하는데, 로드밸런서 뒤에 여러 인스턴스를 두려면 상태가 없는 편이 편해요.
FastMCP("remote-demo", stateless_http=True)로 켜면 매 요청이 독립적으로 처리됩니다.
3. 포트를 여는 순간 위협 모델이 바뀐다
1편에서 “로컬은 외부에서 닿을 소켓 자체가 없다” 고 했죠. 원격은 정반대예요. 엔드포인트가 열리는 순간, 그 주소를 아는 누구든 도구를 호출하려 시도할 수 있습니다. 그래서 최소한 세 가지가 한꺼번에 필요해집니다.
- 인증(Authentication) — 정당한 호출자인지 확인. 토큰 또는 OAuth.
- 전송 암호화(TLS) — 토큰·데이터가 평문으로 흐르지 않게.
https필수. - Origin 검증 — 브라우저를 경유한 DNS 리바인딩 공격 차단. (7장에서 다룸)
이 글의 4·5장은 그중 인증 을 두 갈래로 풀어요. 빠르게 띄우고 싶으면 길 1(Bearer), 스펙을 정석대로 맞추고 싶으면 길 2(OAuth 2.1) 입니다.
4. 인증 길 1 — Bearer 토큰 + ASGI 미들웨어 (실용 추천)
가장 빠르고 견고한 길이에요. FastMCP 의 streamable-http 는 내부적으로 ASGI(Starlette) 앱 이라, 그 앱을 미들웨어로 한 겹 감싸서 Authorization 헤더를 검사하면 됩니다.
# server.py
import os
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
mcp = FastMCP("remote-demo", host="0.0.0.0", port=8000)
@mcp.tool()
def add(a: int, b: int) -> int:
"""두 정수를 더해서 돌려준다."""
return a + b
# 토큰은 코드가 아니라 환경변수에서 읽는다 (하드코딩 금지).
EXPECTED_TOKEN = os.environ["MCP_BEARER_TOKEN"]
class BearerAuthMiddleware(BaseHTTPMiddleware):
"""Authorization: Bearer <token> 헤더를 검사하는 미들웨어."""
async def dispatch(self, request, call_next):
header = request.headers.get("authorization", "")
# 상수시간 비교까지 하려면 hmac.compare_digest 를 쓰는 게 더 안전해요.
if header != f"Bearer {EXPECTED_TOKEN}":
return JSONResponse({"error": "unauthorized"}, status_code=401)
return await call_next(request)
# streamable-http 의 ASGI 앱을 꺼내서 미들웨어를 한 겹 두른다.
app = mcp.streamable_http_app()
app.add_middleware(BearerAuthMiddleware)
이건 mcp.run() 대신 ASGI 서버(uvicorn) 로 띄워요. app 객체를 직접 넘깁니다.
# 토큰은 환경변수로 주입 — 셸 히스토리에 안 남게 별도 관리 권장
export MCP_BEARER_TOKEN="$(openssl rand -hex 32)"
uvicorn server:app --host 0.0.0.0 --port 8000
토큰 비교는 hmac.compare_digest 로 상수시간 비교를 하는 게 더 안전해요. 타이밍 공격 여지를 없애줍니다.
import hmac
def token_ok(header: str) -> bool:
"""'Bearer <token>' 헤더가 기대값과 같은지 상수시간으로 비교."""
prefix = "Bearer "
if not header.startswith(prefix):
return False
return hmac.compare_digest(header[len(prefix):], EXPECTED_TOKEN)
# 기대 토큰이 "secret123" 일 때
print(token_ok("Bearer secret123"))
print(token_ok("Bearer wrong"))
print(token_ok("none"))
True
False
False
✅ 언제 길 1 인가 — 사내망이나 소수 인원이 쓰는 서버, “리버스 프록시 뒤에 두고 토큰 하나로 막으면 충분” 한 상황. 빠르게 띄우고 운영이 단순합니다. 토큰을 사람마다 다르게 발급하고 싶으면 미들웨어에서 토큰→사용자 매핑 테이블을 두면 돼요.
5. 인증 길 2 — OAuth 2.1 TokenVerifier (스펙 정석)
MCP 스펙은 HTTP 트랜스포트의 인증으로 OAuth 2.1 을 정의해요. Claude 같은 호스트가 표준 절차로 토큰을 받아오게 하려면 이 길이 정석입니다. FastMCP 는 우리가 토큰 검증기(TokenVerifier) 만 구현하면 나머지를 붙여줘요.
# 개념 스켈레톤 — 임포트 경로는 SDK 버전에 따라 조금씩 다를 수 있어요.
from mcp.server.fastmcp import FastMCP
from mcp.server.auth.provider import TokenVerifier, AccessToken
from mcp.server.auth.settings import AuthSettings
class MyTokenVerifier(TokenVerifier):
"""들어온 access token 을 우리 인증 서버(IdP)에 물어 검증한다."""
async def verify_token(self, token: str) -> AccessToken | None:
claims = await introspect_with_my_idp(token) # 토큰 introspection
if claims is None or not claims["active"]:
return None # None 을 돌려주면 401 로 거절됨
return AccessToken(
token=token,
client_id=claims["client_id"],
scopes=claims.get("scope", "").split(),
)
mcp = FastMCP(
"remote-demo",
host="0.0.0.0",
port=8000,
token_verifier=MyTokenVerifier(),
auth=AuthSettings(
issuer_url="https://auth.example.com", # 토큰을 발급하는 인증 서버
resource_server_url="https://mcp.example.com", # 이 MCP 서버의 공개 주소
required_scopes=["mcp:use"], # 도구 호출에 요구할 스코프
),
)
여기서 MCP 서버는 자원 서버(Resource Server) 역할만 합니다. 토큰을 직접 발급하지 않고, 별도의 인증 서버(자체 OAuth 서버, Auth0·Keycloak·Okta 같은 IdP)가 발급한 토큰을 검증만 해요. 그래서 길 2를 쓰려면 인증 서버가 따로 있어야 합니다.
💡 길 1 vs 길 2 한 줄 기준 — 쓰는 사람이 소수이고 우리가 토큰을 직접 나눠줄 수 있으면 길 1(Bearer), 외부 사용자에게 표준 OAuth 동의 흐름으로 열어줘야 하거나 이미 IdP 가 있으면 길 2(OAuth 2.1). 처음 띄울 땐 길 1 로 시작해서 필요해지면 길 2 로 올리는 흐름을 추천드려요.
6. 클라이언트 등록 — Claude Code / Claude Desktop
서버를 띄웠으면 호스트에 “이 주소로, 이 토큰으로 붙어라” 를 알려줘야 해요.
6-1. Claude Code
--transport http 로 원격임을 알리고, --header 로 인증 헤더를 같이 넘깁니다.
claude mcp add --transport http remote-demo https://mcp.example.com/mcp \
--header "Authorization: Bearer ${MCP_BEARER_TOKEN}"
OAuth(길 2) 서버라면 헤더 대신 인증 흐름을 타요. 등록 후 세션 안에서 /mcp 를 치면 로그인하라는 안내가 뜨고, 브라우저 동의까지 마치면 연결됩니다.
claude mcp add --transport http remote-demo https://mcp.example.com/mcp
# 세션에서 /mcp → "Authenticate" 흐름
6-2. Claude Desktop
Claude Desktop 설정 파일은 원래 stdio(command)용이라, 커스텀 Bearer 서버는 mcp-remote 브리지 를 끼워 원격 HTTP 로 중계하는 패턴을 많이 써요.
{
"mcpServers": {
"remote-demo": {
"command": "npx",
"args": [
"mcp-remote",
"https://mcp.example.com/mcp",
"--header", "Authorization: Bearer ${MCP_BEARER_TOKEN}"
]
}
}
}
OAuth(길 2) 를 제대로 갖춘 서버라면, Claude Desktop 설정의 커스텀 커넥터(Connectors) UI 에서 URL 만 넣어 표준 OAuth 동의로 붙일 수도 있어요. 이때는 별도 브리지가 필요 없습니다.
🚨 설정에 토큰을 박을 때도 평문 노출에 주의하세요. 환경변수 치환(
${MCP_BEARER_TOKEN})을 쓰고, 설정 파일 자체를 비밀처럼 다뤄야 합니다.
7. 운영 필수 — TLS · Origin 검증 · CORS · 상태
인증을 붙였다고 끝이 아니에요. 원격으로 띄우면 따라붙는 항목들입니다.
| 항목 | 왜 필요한가 | 어떻게 |
|---|---|---|
| TLS(HTTPS) | 토큰·데이터 평문 전송 차단 | 리버스 프록시(Caddy/nginx)에서 인증서 종단. 클라이언트는 https:// 로만 |
| Origin 검증 | 브라우저 경유 DNS 리바인딩 공격 차단 | 허용 Origin 화이트리스트. 신뢰할 수 없는 Origin 헤더는 거절 |
| 바인딩 주소 | 의도치 않은 외부 노출 방지 | 앱은 127.0.0.1 에 띄우고 프록시만 외부에 노출 하는 구성이 안전 |
| CORS | 브라우저 클라이언트 허용 범위 통제 | 필요한 Origin 만 명시. 와일드카드(*) + 인증 동시 사용은 피하기 |
| 상태(state) | 다중 인스턴스 확장 | 수평 확장이 필요하면 stateless_http=True |
🚨 DNS 리바인딩 은 원격 MCP 의 대표 위협이에요. 공격자가 만든 웹페이지가 로컬/내부에 떠 있는 MCP 서버로 요청을 보내게 유도하는 수법인데, 들어온
Origin헤더를 검증 해서 신뢰하지 않는 출처면 거절하는 것이 기본 방어입니다.
8. 배포 예시 — Caddy 리버스 프록시 + systemd
실제로 띄울 땐 앱은 로컬 포트에, TLS·도메인은 프록시가 담당하는 구성이 깔끔해요. Caddy 는 인증서 발급(Let’s Encrypt)을 자동으로 해줘서 특히 편합니다.
Caddyfile — 도메인으로 들어온 HTTPS 를 로컬 8000 으로 넘김.
mcp.example.com {
reverse_proxy 127.0.0.1:8000
}
앱은 127.0.0.1 에만 바인딩해서 프록시만 거치게 합니다.
uvicorn server:app --host 127.0.0.1 --port 8000
서버가 항상 떠 있게 systemd 서비스로 등록해요. (/etc/systemd/system/mcp-remote.service)
[Unit]
Description=Remote MCP server
After=network.target
[Service]
WorkingDirectory=/opt/mcp-remote
Environment=MCP_BEARER_TOKEN=<여기엔_실제값_대신_EnvironmentFile_권장>
ExecStart=/opt/mcp-remote/.venv/bin/uvicorn server:app --host 127.0.0.1 --port 8000
Restart=always
User=mcp
[Install]
WantedBy=multi-user.target
토큰 같은 비밀은 유닛 파일에 평문으로 적기보다 EnvironmentFile=/etc/mcp-remote.env 로 분리하고 그 파일 권한을 600 으로 잠그는 걸 추천드려요.
sudo systemctl daemon-reload
sudo systemctl enable --now mcp-remote
sudo systemctl status mcp-remote
9. 원격 방식의 장단점
1편의 로컬 방식과 정확히 뒤집힌 트레이드오프예요.
장점
| 장점 | 설명 |
|---|---|
| 여러 사람·여러 머신이 공유 | 한 번 띄워두면 팀 전체가 같은 도구를 붙여 씁니다. |
| 중앙에서 관리 | 버전 업·로깅·모니터링을 한 곳에서. 클라이언트는 URL 만 알면 됨. |
| 클라이언트에 런타임 불필요 | 사용자 컴퓨터에 파이썬·의존성이 없어도 됩니다. |
| 표준 인증으로 확장 | OAuth 2.1 로 외부 사용자에게도 안전하게 열 수 있어요. |
단점
| 단점 | 설명 |
|---|---|
| 인증이 필수 | 포트가 열리니 토큰/OAuth 없이는 위험합니다. |
| 운영 부담 | TLS·도메인·Origin 검증·모니터링·재시작 관리가 줄줄이 따라붙어요. |
| 항상 떠 있어야 | 데몬으로 직접 운영. 죽으면 모두가 못 씁니다. |
| 네트워크 지연·공격면 | 왕복 지연이 생기고, 공개 엔드포인트라 공격 표적이 됩니다. |
💡 한 줄 결론 — “내 컴퓨터 안의 것을 나만” 이면 1편의 로컬·stdio, “여러 사람이 같이 쓰거나 항상 떠 있어야 하거나 중앙 관리” 가 필요하면 이 글의 원격·HTTP 입니다. 둘은 코드가 거의 같고, 갈리는 건 트랜스포트와 그 뒤에 붙는 운영·인증이에요.
10. 보안 체크리스트
원격은 공개 엔드포인트라, 1편보다 방어선이 한참 늘어납니다.
- 인증 없이
0.0.0.0노출 금지 — 토큰/OAuth 를 붙이기 전엔127.0.0.1에만. - HTTPS 강제 — 평문 HTTP 로 토큰 흘리지 않기. 프록시에서 TLS 종단.
- 토큰은 환경변수/시크릿 매니저 — 코드·설정·유닛파일에 평문 금지.
EnvironmentFile권한600. - 상수시간 토큰 비교 —
hmac.compare_digest로 타이밍 공격 차단. - Origin 화이트리스트 — DNS 리바인딩 방어.
- 위험한 도구는 읽기 전용/화이트리스트 — 1편과 동일. 원격에선 더 엄격하게.
- 로그에 토큰 안 찍기 —
Authorization=<TOKEN>처럼 가려서 기록. - 토큰 회전 정책 — 유출 대비 주기적 갱신·폐기 경로 마련.
11. 자주 밟는 함정
| 증상 | 원인 | 해결 |
|---|---|---|
| 401 만 떨어짐 | 헤더 형식 불일치 | Authorization: Bearer <token> 정확히. 공백·접두사 확인 |
| 연결은 되는데 도구 호출 실패 | 미들웨어가 초기 핸드셰이크까지 막음 | 인증 예외 경로 점검. 헬스체크 엔드포인트는 분리 |
| 클라이언트가 못 붙음 | http:// 로 접속 / 인증서 문제 |
https:// + 유효한 인증서. 자체서명은 피하기 |
| 외부에서 안 닿음 | 앱이 127.0.0.1 바인딩 |
외부 노출은 프록시가 담당. 방화벽/보안그룹 포트 확인 |
| 가끔 끊김 | 데몬이 죽었다 살아남 | systemd Restart=always, 로그로 원인 추적 |
| 멀티 인스턴스에서 세션 꼬임 | 상태 유지 모드 | stateless_http=True 로 무상태 처리 |
🚨 한 줄 요약 — 원격 MCP 의 8할은 “TLS + 올바른 인증 헤더 + Origin 검증” 에서 갈립니다. 안 붙을 때 이 셋부터 의심하세요.
12. 마무리
2부작을 한 줄로 정리하면 이래요.
- 코드는 거의 같습니다. 1편의 도구 함수가 그대로 살아요. 갈리는 건
mcp.run()의 트랜스포트, 그리고 그 뒤에 붙는 인증·운영입니다. - 로컬·stdio — 포트 0, 인증 불필요, 내 컴퓨터·나만. 군더더기 없음.
- 원격·HTTP — 공유·중앙관리 대신 인증·TLS·Origin 검증·데몬 운영이라는 비용을 치릅니다.
- 인증은 Bearer(빠르고 견고) 로 시작해서, 외부에 열거나 IdP 가 있으면 OAuth 2.1 로 올리세요.
작게 시작하고 싶으면 1편의 stdio 로, 팀에 풀고 싶어지면 이 글의 HTTP 로. 같은 서버 코드를 두 방식으로 굴릴 수 있다는 게 MCP 의 가장 편한 점이에요.
일단 오늘은 여기까지…..
이걸로 MCP 서버 직접 만들기 2부작을 마무리합니다. 다음엔 도구를 여러 개 묶은 실전 서버 사례로 찾아올게요.
← 이전 글: (1/2) MCP 서버를 로컬·Claude 전용으로 만들기 — Python(FastMCP) stdio 완벽 가이드