Skip to content
신선한 자몽 농장
Go back

여러 환경에서 공통된 Skill 사용을 위한 MCP 구성

개요

Claude Code와 Codex를 여러 머신에서 사용하다 보니 Skill 파일 관리가 번거로워졌다. install.sh로 각 머신마다 파일을 복사해서 설치하는 방식이라, 업데이트할 때마다 모든 환경을 따로 갱신해야 했다.

해결책으로 Skill을 MCP 서버로 서빙하는 방식을 택했다. 서버 URL 하나로 연결하면 git pull 한 번으로 모든 클라이언트에 즉시 반영된다. 구축 과정에서 만난 오류들을 기록해둔다.


아키텍처

Claude Code / Codex (여러 머신)
  ↓ HTTPS
Cloudflare (DNS + Proxy)
  ↓ HTTPS
Nginx Proxy Guard (리버스 프록시, SSL 처리)
  ↓ HTTP
Proxmox VM (Rocky Linux 9 Minimal)
  └── jamong-mcp.service (Python FastMCP)
        └── /opt/jamong-harvest/skills/ 직접 읽기

이미 보유한 인프라를 그대로 활용했다. Proxmox 홈 서버, Rocky Linux, NPM, Cloudflare — 새로 구성할 것이 없었다.


VM 사양과 초기 설정

스킬 파일(텍스트)만 서빙하므로 최소 사양으로 충분하다. vCPU 1코어, RAM 512MB, 디스크 8GB.

Rocky Linux 9 Minimal 기준 필수 패키지:

dnf install -y python3.11 python3.11-pip git
firewall-cmd --permanent --add-port=<포트>/tcp
firewall-cmd --reload

서버 기본 코드

mcp/server.py의 핵심 구조다. Skill 디렉터리를 읽어서 리소스로 서빙하는 것이 전부다.

#!/usr/bin/env python3
import os
import uvicorn
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings

SKILLS_DIR = Path(__file__).parent.parent / "skills"
mcp = FastMCP("jamong-skills")

@mcp.resource("skills://list")
def list_skills() -> str:
    names = sorted(
        d.name for d in SKILLS_DIR.iterdir()
        if d.is_dir() and (d / "SKILL.md").exists()
    )
    return "\n".join(names)

@mcp.resource("skill://{name}")
def get_skill(name: str) -> str:
    path = SKILLS_DIR / name / "SKILL.md"
    if not path.exists():
        raise ValueError(f"Skill not found: {name}")
    return path.read_text(encoding="utf-8")

if __name__ == "__main__":
    port = int(os.environ.get("PORT"))
    mcp_host = os.environ.get("MCP_HOST", "localhost")
    mcp.settings.transport_security = TransportSecuritySettings(
        enable_dns_rebinding_protection=True,
        allowed_hosts=[mcp_host],
    )
    mcp.settings.host = mcp_host
    mcp.settings.port = port
    uvicorn.run(mcp.streamable_http_app(), host="0.0.0.0", port=port)

1부: 서버 구동 — 오류 해결

오류 1: ModuleNotFoundError — mcp 모듈을 찾지 못함

install.sh를 일반 유저로 실행해 패키지가 유저 홈에 설치됐다. 그런데 systemd 서비스는 root로 실행되므로 해당 패키지를 찾지 못했다.

venv로 격리해서 해결했다.

python3.11 -m venv /opt/jamong-harvest/venv
/opt/jamong-harvest/venv/bin/pip install "mcp[cli]>=1.9.0"

systemd의 ExecStart도 venv python으로 변경:

ExecStart=/opt/jamong-harvest/venv/bin/python /opt/jamong-harvest/mcp/server.py

오류 2: TypeError — FastMCP.run()에 host 인자 없음

mcp.run(transport="sse", host="0.0.0.0", port=<포트>)처럼 호출했더니 unexpected keyword argument 'host' 오류가 발생했다. FastMCP의 run()은 host/port를 직접 받지 않는다.

mcp.settings로 설정하는 방식으로 변경했다.

mcp.settings.host = "0.0.0.0"
mcp.settings.port = <포트>
mcp.run(transport="sse")

오류 3: SSE transport deprecated

MCP SDK 1.9+에서 sse 트랜스포트가 deprecated 처리됐다. Request validation 오류가 발생해서 streamable-http로 전환했다. 엔드포인트도 /sse/mcp로 바뀐다.

mcp.run(transport="streamable-http")

오류 4: HTTP 400 — 중복 Host 헤더

NPM Advanced 설정에서 proxy_set_header Host $host; 등을 직접 추가했는데, NPM이 동일한 헤더를 기본으로도 추가한다.

중복 Host 헤더가 발생하면 uvicorn의 HTTP 파서(h11)가 요청을 거부한다.

nc -l <포트>으로 실제 수신 패킷을 직접 확인해서 발견했다.

Host: mcp.your-domain.com
...
Host: mcp.your-domain.com  ← 중복!

NPM Advanced 탭에서 직접 추가한 헤더 설정을 전부 제거하고 아래만 남겼다. 나머지는 NPM이 자동으로 추가한다.

proxy_buffering off;

오류 5: HTTP 421 — Invalid Host header

MCP SDK의 DNS rebinding 보호 미들웨어 문제였다. allowed_hosts의 기본값이 빈 리스트([])인데, 이 상태에서는 모든 Host를 거부한다.

transport_security.py 소스에서 직접 확인했다.

allowed_hosts: list[str] = Field(default=[], ...)

def _validate_host(self, host):
    if host in self.settings.allowed_hosts:  # 빈 리스트 → 항상 False
        return True
    return False

FastMCP settings 필드 목록은 아래 명령으로 확인할 수 있다.

python3 -c "from mcp.server.fastmcp import FastMCP; mcp = FastMCP('t'); print(list(mcp.settings.model_fields.keys()))"
# → [..., 'transport_security']

TransportSecuritySettings에 외부 도메인을 명시적으로 등록해서 해결했다.

from mcp.server.transport_security import TransportSecuritySettings

mcp.settings.transport_security = TransportSecuritySettings(
    enable_dns_rebinding_protection=True,
    allowed_hosts=["mcp.your-domain.com"],
)

2부: Claude Code 연결 — OAuth 구현

서버가 올라간 뒤 Claude Code에서 연결을 추가했더니 예상 밖의 상태가 떴다.

jamong-skills — connected (no tools)

연결은 됐는데 아무것도 쓸 수 없었다. 서버 로그를 보니 Claude Code가 /.well-known/oauth-authorization-server를 찾고 있었다. Claude Code는 외부 MCP 서버에 접근할 때 OAuth 2.0 Authorization Code Flow (PKCE)를 강제한다. 엔드포인트가 없으니 “연결됐지만 아무것도 못 씀” 상태가 된 것이다.


OAuth 구현

MCP Python SDK v1.28.0에는 OAuthAuthorizationServerProvider 추상 클래스가 있다. 이걸 구현하면 FastMCP가 필요한 OAuth 엔드포인트를 자동으로 마운트해준다.

자동 마운트되는 엔드포인트:

추가로 /login, /login/callback 라우트를 직접 구현해서 브라우저 로그인 페이지를 달았다.

from mcp.server.fastmcp import FastMCP as MCPServer
from mcp.server.auth.provider import (
    AccessToken, AuthorizationCode, AuthorizationParams,
    OAuthAuthorizationServerProvider, RefreshToken, construct_redirect_uri,
)
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions

class JamongOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
    def __init__(self, server_url: str):
        self.server_url = server_url.rstrip("/")
        self.username = os.environ.get("MCP_USERNAME", "admin")
        self.password = os.environ.get("MCP_PASSWORD", "changeme")
        self.clients: dict[str, OAuthClientInformationFull] = {}
        self.auth_codes: dict[str, AuthorizationCode] = {}
        self.tokens: dict[str, AccessToken] = {}
        self.state_mapping: dict[str, dict] = {}

    async def authorize(self, client, params: AuthorizationParams) -> str:
        state = params.state or secrets.token_hex(16)
        self.state_mapping[state] = {
            "redirect_uri": str(params.redirect_uri),
            "code_challenge": params.code_challenge,
            "client_id": client.client_id,
            "resource": params.resource,
        }
        return f"{self.server_url}/login?state={state}"

    async def exchange_authorization_code(self, client, authorization_code) -> OAuthToken:
        token = f"tok_{secrets.token_hex(32)}"
        self.tokens[token] = AccessToken(
            token=token,
            client_id=client.client_id,
            scopes=authorization_code.scopes,
            expires_at=int(time.time()) + 86400,
        )
        del self.auth_codes[authorization_code.code]
        return OAuthToken(access_token=token, token_type="Bearer", expires_in=86400)

로그인 콜백에서 아이디/비밀번호를 확인하고 Authorization Code를 발급한다.

@mcp.custom_route("/login/callback", methods=["POST"])
async def login_callback(request: Request) -> Response:
    form = await request.form()
    if form["username"] != provider.username or form["password"] != provider.password:
        raise HTTPException(401, "Invalid credentials")

    code = f"code_{secrets.token_hex(16)}"
    provider.auth_codes[code] = AuthorizationCode(
        code=code,
        client_id=state_data["client_id"],
        redirect_uri=AnyHttpUrl(state_data["redirect_uri"]),
        expires_at=time.time() + 300,
        scopes=["mcp"],
        code_challenge=state_data["code_challenge"],
        subject=form["username"],
    )
    redirect = construct_redirect_uri(state_data["redirect_uri"], code=code, state=state)
    return RedirectResponse(url=redirect, status_code=302)

OAuth 구현 중 오류들

오류 1: ModuleNotFoundError — mcp.server.mcpserver 없음

GitHub main 브랜치 예제를 보고 from mcp.server.mcpserver import MCPServer를 썼는데, PyPI 1.28.0에는 해당 모듈이 없다. PyPI 릴리즈 기준으로는 fastmcp가 같은 API를 지원한다.

from mcp.server.fastmcp import FastMCP as MCPServer

오류 2: Pydantic ValidationError — resource_server_url 필수 필드

AuthSettingsresource_server_url 필드가 필수였다. standalone auth server 구성이면 None으로 명시해야 한다.

auth=AuthSettings(
    issuer_url=AnyHttpUrl(server_url),
    resource_server_url=None,  # 명시 필요
    ...
)

오류 3: streamable_http_app() 인자 오류

uvicorn.run(mcp.streamable_http_app(host=mcp_host), ...)처럼 넘기면 안 된다. mcp.settings에 직접 할당해야 한다.

# 잘못된 방식
uvicorn.run(mcp.streamable_http_app(host=mcp_host), host="0.0.0.0", port=port)

# 올바른 방식
mcp.settings.transport_security = TransportSecuritySettings(
    enable_dns_rebinding_protection=True,
    allowed_hosts=[mcp_host],
)
mcp.settings.host = mcp_host
mcp.settings.port = port
uvicorn.run(mcp.streamable_http_app(), host="0.0.0.0", port=port)

환경변수 분리

비밀번호를 서비스 파일에 인라인으로 넣으면 안 된다. EnvironmentFile= 방식으로 분리했다.

# jamong-mcp.service
[Service]
EnvironmentFile=__ENV_FILE__
ExecStart=__VENV_DIR__/bin/python __REPO_DIR__/mcp/server.py
# jamong-mcp.env (git 제외)
PORT=<포트>
MCP_HOST=mcp.your-domain.com
SERVER_URL=https://mcp.your-domain.com
MCP_USERNAME=admin
MCP_PASSWORD=your_password_here

특수문자가 포함된 비밀번호는 따옴표 없이 그냥 쓴다. EnvironmentFile은 shell이 아니라서 따옴표가 값에 포함된다.

MCP_PASSWORD="pass@word!"  # 잘못됨 — 따옴표까지 비밀번호에 들어감
MCP_PASSWORD=pass@word!    # 올바름

검증 및 Claude Code 연결

curl https://mcp.your-domain.com/.well-known/oauth-authorization-server
{
  "issuer": "https://mcp.your-domain.com",
  "authorization_endpoint": "https://mcp.your-domain.com/authorize",
  "token_endpoint": "https://mcp.your-domain.com/token",
  "registration_endpoint": "https://mcp.your-domain.com/register",
  "scopes_supported": ["mcp"],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"]
}

Claude Code 연결 설정:

{
  "mcpServers": {
    "jamong-skills": {
      "type": "http",
      "url": "https://mcp.your-domain.com/mcp"
    }
  }
}

/mcp로 연결하면 브라우저 로그인 창이 뜨고, 인증 후 정상 연결된다.

Authentication successful. Connected to jamong-skills.

리소스 확인:

skills://list → admin-safety, code-discipline, completion-report, deploy, git-workflow, versioning
skill://git-workflow → (스킬 전체 내용)

핵심 요약

삽질원인해결
패키지 못 찾음venv 미사용, 유저/root 환경 불일치venv 격리, journalctl로 확인
HTTP 400NPM 중복 Host 헤더Advanced 탭 헤더 설정 전부 제거
HTTP 421MCP SDK DNS rebinding 보호 기본값 []allowed_hosts에 외부 도메인 명시
SSE deprecatedSDK 1.9+ 버전 업streamable-http 전환
connected (no tools)OAuth 엔드포인트 미구현OAuthAuthorizationServerProvider 구현
mcpserver 모듈 없음GitHub/PyPI 버전 불일치fastmcp로 대체
resource_server_url 오류Pydantic 필수 필드 누락resource_server_url=None 명시
특수문자 비밀번호 오인식EnvironmentFile은 shell 아님따옴표 없이 그냥 씀

전체 코드는 Jamong-Harvest mcp/ 디렉터리에 있다.


서버 구동까지는 5개, OAuth 붙이면서 3개 더 — 오류 수로만 보면 꽤 험난했다. 그런데 막상 하나씩 뚫고 나니 이제는 어느 머신에서든 URL 하나로 Skill을 불러쓸 수 있게 됐다. 삽질한 만큼 구조는 단단해졌다.



Previous Post
Proxmox VE 8 → 9 업그레이드와 안일했던 과거의 나
Next Post
AI 시대에서 비개발자로 살아남기