9 분 소요

📚 MCP 서버 직접 만들기 시리즈 (전체 2편)

  1. 로컬·Claude 전용으로 만들기 — Python(FastMCP) stdio
  2. 원격 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 완벽 가이드