홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

MCP 서버를 OAuth 2.1로 상주화하기 — LaunchAgent + 자동 재빌드 watcher (2편)

정기창·2026년 4월 21일

1편에서 MCP 서버가 OAuth 2.1 HTTP transport로 빌드됐는데 .mcp.json은 stdio spec으로 남아 있어 연결이 되지 않던 상황을 진단했습니다. 이번 글은 그 해결 과정 — OAuth 흐름 이해 + macOS LaunchAgent로 상주 + 1시간 주기 자동 재빌드 watcher 구축까지 — 을 실전 수준의 파일과 함께 기록합니다.

OAuth 참가자 관계부터

제가 처음에 가장 혼란스러웠던 부분은 "누가 OAuth 서버이고 누가 클라이언트인가"였습니다. 보통 OAuth 하면 Google, GitHub 같은 외부 Identity Provider를 떠올리는데, MCP 환경은 그렇지 않았습니다.

Claude Code (MCP Client)
    │
    │ 1) Discovery (/.well-known/*)
    │ 2) authorize → 사용자 로그인
    ▼
Blog MCP Server (Authorization + Resource Server)
  localhost:3004  — LaunchAgent로 상주
    │
    │ 3) 입력받은 email/password를 backend에 위임
    ▼
Backend API (기존 인증 서버)
  POST /auth/login → JWT 반환

핵심은 Blog MCP 서버 자체가 Authorization Server라는 점입니다. 외부 IdP를 쓰지 않고, MCP가 자기 프로세스 안에서 OAuth 엔드포인트 전체(/authorize, /token, /register, /.well-known/*)를 노출합니다. 사용자 인증은 기존 블로그 backend에 위임하고, 인증이 성공하면 MCP가 자체 JWT를 발급합니다. 그 JWT가 이후 모든 POST /mcp 요청의 Bearer 토큰이 됩니다.

mcpAuthRouter — 표준 SDK의 편의

이 모든 걸 수동으로 구현했다면 버그가 몇십 개 있었을 겁니다. 다행히 @modelcontextprotocol/sdk가 mcpAuthRouter라는 편의 함수를 제공합니다.

import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';

app.use(mcpAuthRouter({
  provider: oauthProvider,
  issuerUrl: new URL(ISSUER_URL),
  scopesSupported: ['blog:read', 'blog:write'],
  resourceName: 'Blog MCP',
}));

const bearerAuth = requireBearerAuth({ verifier: oauthProvider });
app.post('/mcp', bearerAuth, express.json(), async (req, res) => { /* ... */ });

이 한 번의 app.use로 Discovery 엔드포인트들이 자동 노출됩니다. 실제로 기동 후 확인해보면 이렇게 나옵니다.

$ curl -sS http://localhost:3004/.well-known/oauth-authorization-server | jq
{
  "issuer": "http://localhost:3004/",
  "authorization_endpoint": "http://localhost:3004/authorize",
  "token_endpoint": "http://localhost:3004/token",
  "registration_endpoint": "http://localhost:3004/register",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["blog:read", "blog:write"]
}

$ curl -sS -X POST http://localhost:3004/mcp -H 'Content-Type: application/json' -d '{}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header"

Claude Code는 이 표준 응답을 보고 자동으로 authenticate tool을 생성합니다. Notion이나 Gmail MCP를 쓸 때 이미 경험해본 그 플로우와 같습니다.

LaunchAgent #1 — MCP 서버 상주

자동 로그인과 별개로 서버 프로세스가 항상 떠 있어야 Claude Code가 접속할 수 있습니다. macOS의 launchd가 이 역할에 가장 적합합니다. ~/Library/LaunchAgents/com.kichang.blog-mcp.plist를 이렇게 만들었습니다.

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.kichang.blog-mcp</string>

  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/node</string>
    <string>/Users/me/my_blog/packages/personal/mcp/dist/index.js</string>
  </array>

  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    <key>NODE_ENV</key><string>production</string>
    <key>PORT</key><string>3004</string>
    <key>BLOG_API_URL</key><string>https://api.example.com</string>
    <!-- BLOG_API_TOKEN, MCP_JWT_SECRET 은 런타임 주입 -->
  </dict>

  <key>RunAtLoad</key><true/>

  <key>KeepAlive</key>
  <dict>
    <key>SuccessfulExit</key><false/>
    <key>Crashed</key><true/>
  </dict>

  <key>ThrottleInterval</key><integer>10</integer>

  <key>StandardOutPath</key>
  <string>/Users/me/Library/Logs/blog-mcp/stdout.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/me/Library/Logs/blog-mcp/stderr.log</string>
</dict>
</plist>

KeepAlive를 boolean 대신 dictionary로 지정한 이유가 있습니다. 정상 종료(SuccessfulExit=true)에는 재기동하지 않고, 크래시 상황에서만 재기동하도록 제한하기 위해서입니다. 개발 중 의도적 SIGTERM이 무한 재부팅 루프로 이어지는 사고를 막습니다.

JWT Secret 안전 주입

비밀 값은 .plist에 평문으로 넣어도 사용자 소유 파일이지만, 여전히 터미널 로그나 git diff에 흘리지 않는 편이 좋습니다. PlistBuddy로 조용히 주입했습니다.

NEW_SECRET=$(openssl rand -hex 32)
# 시크릿 값은 절대 echo 하지 않고 즉시 plist에 넣음
/usr/libexec/PlistBuddy -c \
  "Add :EnvironmentVariables:MCP_JWT_SECRET string $NEW_SECRET" \
  ~/Library/LaunchAgents/com.kichang.blog-mcp.plist

# 임시로 파일에 썼다면 shred로 안전 삭제
shred -u /tmp/secret_once 2>/dev/null || rm -P /tmp/secret_once
plutil -lint ~/Library/LaunchAgents/com.kichang.blog-mcp.plist

plutil -lint는 plist 문법 오류를 잡아줍니다. 한 번 누락하면 launchd가 조용히 실패하고 디버깅이 어려워지므로 꼭 돌렸습니다.

LaunchAgent #2 — 1시간 주기 watcher

요구사항 중 하나가 "main 브랜치에 MCP 관련 변경이 생기면 자동으로 pull + rebuild + 재기동" 이었습니다. inotify 기반 파일 감시는 macOS에서 번거롭기 때문에, 단순 주기 실행으로 가기로 했습니다.

<dict>
  <key>Label</key>
  <string>com.kichang.blog-mcp-watcher</string>

  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/me/bin/blog-mcp-watcher.sh</string>
  </array>

  <key>StartInterval</key><integer>3600</integer>

  <key>RunAtLoad</key><false/>
</dict>

1시간 한 번이면 충분하다고 봤습니다. MCP 코드 변경은 드물고, 빠른 반영이 필요할 때는 수동 재시작 스크립트를 따로 뒀기 때문입니다(뒤에서 소개).

watcher 스크립트의 안전 가드

자동화에서 가장 무서운 건 엉뚱한 타이밍에 pull이 도는 것입니다. 제가 다른 브랜치에서 작업 중이거나 uncommitted 변경을 들고 있을 때 watcher가 깨면 어떤 일이 벌어질지 상상만 해도 불편했습니다. 그래서 안전 가드를 여러 겹 뒀습니다.

#!/bin/bash
set -uo pipefail
REPO="/Users/me/my_blog"
LABEL="com.kichang.blog-mcp"
UID_LOCAL=$(id -u)

cd "$REPO" || exit 1

# 가드 1: main 브랜치만
CURRENT=$(git rev-parse --abbrev-ref HEAD)
[ "$CURRENT" != "main" ] && { echo "skip: not main"; exit 0; }

# 가드 2: clean 상태만
[ -n "$(git status --porcelain)" ] && { echo "skip: dirty"; exit 0; }

# 가드 3: fetch 후 실제 변경 여부 확인
git fetch origin main --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
[ "$LOCAL" = "$REMOTE" ] && exit 0

# 가드 4: MCP 관련 파일이 변경됐을 때만 rebuild
CHANGED=$(git diff --name-only HEAD origin/main)
if echo "$CHANGED" | grep -qE '^(packages/personal/mcp/|packages/tools/shared-types/|pnpm-lock\.yaml$)'; then
  NEED_REBUILD=true
else
  NEED_REBUILD=false
fi

git pull origin main --ff-only || {
  notify_slack "blog-mcp watcher: pull failed"
  exit 1
}

$NEED_REBUILD || exit 0

pnpm install --frozen-lockfile --prefer-offline \
  && pnpm --filter @my-blog/shared-types build \
  && pnpm --filter @my-blog/blog-mcp build \
  || { notify_slack "blog-mcp watcher: build failed"; exit 1; }

launchctl kickstart -k "gui/$UID_LOCAL/$LABEL"
notify_slack "blog-mcp 자동 재배포 완료: $(git rev-parse --short HEAD)"

네 겹의 가드 덕분에 제가 실수해도 watcher가 엉뚱한 일을 하지 않습니다. 실제로 설치 직후 수동 kick했을 때 uncommitted 변경 때문에 깔끔하게 skip되는 걸 보고 안심했습니다.

그리고 빌드 실패 시 launchctl kickstart를 호출하지 않습니다. 이게 은근히 중요했는데, 이전 빌드의 dist/는 그대로 살아 있으므로 서비스는 중단되지 않고 알림만 옵니다. 다음 번에 수동으로 원인을 찾아 고치면 됩니다.

수동 재시작 스크립트

1시간 기다리기 싫을 때를 위해 ~/bin/blog-mcp-restart.sh를 하나 뒀습니다.

$ blog-mcp-restart.sh --status    # 현재 상태만 출력
$ blog-mcp-restart.sh             # 빌드 + 재기동
$ blog-mcp-restart.sh --no-build  # 이미 빌드된 dist로 재기동만

Claude Code 세션 안에서 이걸 호출하면 지금 이 순간 코드 변경을 반영할 수 있습니다. 제가 블로그 MCP 코드를 만질 때 가장 자주 쓰게 된 명령이 됐습니다.

최종 검증

설치가 끝난 뒤 체크리스트로 한 번씩 훑었습니다.

✓ launchctl list | grep kichang.blog    (2개 모두 로드)
✓ lsof -i :3004 -P                       (LISTEN)
✓ /.well-known/oauth-authorization-server  HTTP 200
✓ /.well-known/oauth-protected-resource    HTTP 200
✓ POST /mcp (무인증)                     HTTP 401 + WWW-Authenticate
✓ watcher 수동 kick → "skip: dirty"      (안전 가드 동작)

그 다음 Claude Code를 재시작하니 blog MCP가 정상 연결됐고, OAuth authenticate flow가 자동으로 열려 브라우저에서 로그인 한 번으로 끝났습니다. 60일 refresh 덕분에 당분간은 잊고 살면 됩니다.

회고

사소해 보이는 "Failed to connect" 한 줄을 따라가다 OAuth 2.1 Discovery, macOS launchd, bash 안전 가드, 시크릿 주입, 멱등 배포까지 전부 손대게 됐습니다. 과한 작업이었을까 잠깐 생각했는데, 지금은 오히려 "개인 개발 도구도 프로덕션처럼" 설계해 둔 것이 장기 품질에 이롭다고 느낍니다. 시크릿 로테이션, 서비스 무중단, 실패 알림, 수동 트리거 같은 인프라 덕목이 모두 1인 블로그 같은 작은 시스템에서도 의미가 있다는 걸 다시 확인한 계기였습니다.

비슷하게 "내 로컬 MCP 서버 하나를 제대로 띄우고 싶다"는 분이라면, Discovery → 401 challenge → authenticate tool 흐름을 우선 이해하고, 상주는 launchd의 KeepAlive={Crashed: true}, 자동화는 StartInterval + 안전 가드 셸 스크립트 조합이 단순하면서도 잘 작동하는 해결책이라고 말씀드리겠습니다.

Claude CodeMCPOAuthLaunchAgentmacOS

관련 글

MCP 서버 연결 실패 디버깅 — stdio vs HTTP transport 불일치 (1편)

claude mcp list에서 "Failed to connect"가 반복되던 MCP 서버를 추적하다 stdio와 HTTP Streamable transport의 근본 불일치를 발견했습니다. OAuth 도입 리팩토링이 남긴 흔적과 3가지 해결 경로, 그리고 제가 왜 OAuth 2.1 HTTP를 최종 선택했는지 정리했습니다.

관련도 94%

Claude Code에서 블로그 글 작성하기: MCP를 직접 만들어 활용한 경험

Claude Code로 개발하면서 얻은 지식을 블로그에 정리하고 싶었습니다. 하지만 AI에게 블로그 관리자 권한을 모두 주기엔 불안했고, 필요한 API만 분기 처리하기엔 번거로웠습니다. MCP(Model Context Protocol)를 직접 만들어서 권한을 명확히 분리하고, Few-shot 예시와 SEO 가이드라인을 1회 호출로 제공하도록 개선한 경험을 공유합니다.

관련도 91%

Claude Code에 Google Search Console MCP를 추가하고, OAuth 토큰 만료를 해결한 경험

GA4 MCP를 운영하다 OAuth 토큰이 만료되고, GSC MCP를 추가하면서 gcloud CLI 설치 오류까지 겪었습니다. ADC 토큰 갱신부터 Python 호환성 문제까지, 실전에서 마주친 트러블슈팅 과정을 정리했습니다.

관련도 90%