> ## Content Index
> Fetch the complete content index at: https://blog.sejong.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# NAS에 Claude Code 상주시키기: MCP와 Remote Control
- URL: https://blog.sejong.dev/claude-code-on-nas/
- Published: 2026-10-06T12:53:50.000Z
- Updated: 2026-10-09T15:19:02.000Z
- Description: Jenkins·Gitea를 MCP로 Claude에 연결하고, Claude Code를 NAS 컨테이너에 띄워 휴대폰에서 Remote Control로 접속하게 한 기록. 토큰을 어디에 두고 권한을 얼마나 줄지 정한 과정.
- Author: Sejong Yoo
- Tags: 홈서버, Docker, Jenkins, Claude Code, MCP, 개발, 서버

집 NAS는 Claude Code로 관리한다. Windows PC에서 Claude Code를 열고, SSH로 NAS에 붙어 컨테이너를 점검하고, Caddy 설정을 고치고, 작업 기록을 git 저장소에 남긴다. 편했지만 두 가지가 아쉬웠다. Claude가 보는 건 SSH 터미널뿐이라 Jenkins 빌드 로그나 Gitea 이슈는 내가 복사해서 붙여 줘야 했다. 그리고 PC가 꺼져 있으면 아무것도 할 수 없었다. 밖에서 휴대폰으로 "컨테이너 하나 재시작해 줘" 정도는 시키고 싶었다.

이 글은 그 두 가지를 해결한 기록이다. 하나는 **MCP**로 Jenkins·Gitea를 Claude에 직접 연결한 것, 다른 하나는 Claude Code를 **NAS 컨테이너에 상주**시키고 **Remote Control**로 휴대폰에서 접속하게 한 것이다.

## MCP: AI에게 도구를 쥐여 주는 규격

MCP(Model Context Protocol)는 AI 클라이언트가 외부 도구를 부르는 방식을 정해 둔 규격이다. 서버가 "이런 도구가 있다"(`get_build_log`, `list_issues` 같은)고 알리면, Claude가 대화 중에 필요한 도구를 골라 호출한다. 서버 쪽은 표준 입출력으로 붙는 로컬 프로세스일 수도 있고, HTTP 엔드포인트일 수도 있다. 집 서버에서는 HTTP 쪽이 편하다. 서버를 컨테이너 하나로 띄워 두면 PC든 NAS든 주소만 등록하면 된다.

붙인 건 두 개다.

| MCP 서버  | 형태                               | 인증                         | 노출                         |
| ------- | -------------------------------- | -------------------------- | -------------------------- |
| Jenkins | Jenkins 플러그인 mcp-server (도구 21개) | Jenkins 사용자 API 토큰 (Basic) | Jenkins 경로 /mcp-server/mcp |
| Gitea   | 공식 gitea-mcp-server 컨테이너         | Gitea 액세스 토큰 (Bearer)      | LAN 전용 포트                  |

### Jenkins: 플러그인 하나, 그런데 경로가 함정

Jenkins는 MCP 서버를 플러그인으로 제공한다. 설치하면 Jenkins 자신이 Streamable HTTP 엔드포인트가 된다. 컨테이너 안에서 `jenkins-plugin-cli`로 설치했는데, 처음에는 재시작해도 플러그인이 보이지 않았다.

```bash
# 경로를 안 주면 /usr/share/jenkins/ref/plugins 에 받아진다 (이미지 빌드용 경로)
jenkins-plugin-cli --plugins mcp-server

# 실행 중인 Jenkins가 읽는 곳은 JENKINS_HOME
jenkins-plugin-cli --plugin-download-directory /var/jenkins_home/plugins --plugins mcp-server
```

`jenkins-plugin-cli`의 기본 경로는 Docker 이미지를 만들 때 플러그인을 미리 넣어 두는 `ref` 폴더다. 이미 돌고 있는 Jenkins에 넣으려면 `JENKINS_HOME/plugins`를 직접 지정해야 한다. 의존성(`jackson3-api`)까지 받고 재시작하자 도구 21개로 초기화됐다. 빌드 목록·로그 검색·재빌드·테스트 결과 같은 것들이다.

인증은 Jenkins 사용자 API 토큰이다. 관리자 계정 대신 Claude용 `claude` 계정을 따로 만들어 토큰을 발급했다.

### Gitea: 서버에는 토큰을 두지 않는다

Gitea는 공식 MCP 서버 이미지가 있다. 표준 입출력 모드가 기본인데, HTTP 모드로 띄워 상주시켰다.

```yaml
# Gitea MCP Server (HTTP, LAN 전용). 토큰은 서버에 두지 않고 클라이언트가 Authorization 헤더로 보낸다.
services:
  gitea-mcp:
    image: docker.gitea.com/gitea-mcp-server:latest
    container_name: gitea-mcp
    command: ["/app/gitea-mcp", "-t", "http", "-p", "8080"]
    environment:
      GITEA_HOST: http://192.168.50.10:13330
    ports:
      - "13331:8080"
    restart: unless-stopped
```

환경 변수로 토큰을 넣을 수도 있지만 일부러 넣지 않았다. 서버에 토큰이 있으면 이 포트에 닿는 누구나 내 권한으로 Gitea를 만질 수 있다. 토큰을 클라이언트가 `Authorization` 헤더로 보내게 하면 서버는 그냥 중계기가 된다. 헤더 없이 부르면 `token is required`로 거절한다. 그래도 리버스 프록시에는 올리지 않고 LAN에서만 열어 두었다.

등록은 Claude Code CLI 한 줄이다.

```bash
claude mcp add --transport http gitea http://192.168.50.10:13331/mcp \
  --header "Authorization: Bearer <토큰>"
```

토큰 권한은 필요한 만큼만 줬다. 저장소·이슈 쓰기, 사용자·조직·알림 읽기. 관리자 권한은 없다.

### 토큰은 화면에 남기지 않는다

토큰을 넣는 일은 스크립트로 한다. 입력이 화면에 보이지 않고, 등록 전에 토큰이 맞는지 먼저 확인한다.

```bash
read -r -s -p "API 토큰 (화면에 표시되지 않음): " TOKEN
echo
[ -n "$TOKEN" ] || { echo "토큰이 비어 있음 — 중단"; exit 1; }

# 등록 전에 토큰이 실제로 통하는지 확인
code=$(curl -s -o /dev/null -w '%{http_code}' -u "$USER_ID:$TOKEN" "$URL/me/api/json")
[ "$code" = 200 ] || { echo "토큰 확인 실패 (HTTP $code)"; exit 1; }

AUTH=$(printf '%s:%s' "$USER_ID" "$TOKEN" | base64 -w0)
claude mcp add --scope user --transport http jenkins "$URL/mcp-server/mcp" \
  --header "Authorization: Basic $AUTH" | grep -v Authorization
unset TOKEN AUTH
```

출력에서 `Authorization` 줄을 거르는 것도 같은 이유다. 토큰은 터미널 기록에도, Claude와의 대화에도 남기지 않는다.

## Claude Code를 NAS에 상주시키기

MCP를 붙이자 PC에서는 할 수 있는 일이 많아졌다. 남은 문제는 PC가 꺼져 있을 때다. Claude Code에는 **Remote Control**이라는 기능이 있다. `claude remote-control`을 실행해 두면 그 머신의 Claude Code가 claude.ai/code와 Claude 앱의 세션 목록에 나타나고, 휴대폰이나 브라우저에서 그 세션에 말을 걸 수 있다. 실제 작업은 그 머신에서 돈다.

그렇다면 24시간 켜져 있는 NAS에서 돌리면 된다. 연결은 NAS에서 바깥으로 나가는 아웃바운드뿐이라 포트를 열 필요도 없다.

### 컨테이너 구성

DSM에 직접 설치하는 대신 컨테이너로 만들었다. 이미지는 최소한의 도구만 넣은 Debian이다.

```dockerfile
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates curl git openssh-client tmux ripgrep jq less procps \
      iputils-ping dnsutils nano locales \
 && rm -rf /var/lib/apt/lists/*

# NAS 사용자(uid 1026, gid 100 users)와 맞춰 바인드 마운트 권한 문제를 없앤다
RUN useradd -u 1026 -g 100 -m -s /bin/bash claude

COPY --chmod=755 entrypoint.sh rc-loop.sh /usr/local/bin/

ENV LANG=C.UTF-8 TZ=Asia/Seoul PATH=/home/claude/.local/bin:$PATH
USER claude
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
```

Claude Code 자체는 이미지에 넣지 않았다. 홈 폴더를 볼륨으로 빼고, 처음 기동할 때 공식 설치 스크립트로 그 안에 설치한다. 이렇게 하면 Claude Code가 스스로 자동 업데이트해도 컨테이너를 다시 만들 때 사라지지 않고, 로그인 정보·MCP 설정·SSH 키도 함께 남는다. 이미지는 거의 바뀔 일이 없으니 Watchtower 대상에서도 뺐다.

```bash
#!/bin/bash
# 컨테이너 시작: Claude Code 설치(홈 볼륨에 영구 보관, 이후 자동 업데이트) → tmux에서 Remote Control 루프
set -e
cd "$HOME"

if [ ! -x "$HOME/.local/bin/claude" ]; then
  curl -fsSL https://claude.ai/install.sh | bash
fi

if [ ! -f "$HOME/.ssh/id_ed25519" ]; then
  mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
  ssh-keygen -q -t ed25519 -N '' -C claude@sejong-nas -f "$HOME/.ssh/id_ed25519"
fi

# tmux 세션 main: 창 0 = Remote Control 서버, 이후 창은 수동 작업용
exec tmux -u new-session -s main -n rc /usr/local/bin/rc-loop.sh
```

tmux 안에서 돌리는 이유는 사람이 들여다볼 창구가 필요해서다. 권한 확인 프롬프트가 떠 있거나 로그인이 풀렸을 때 `docker exec -it claude-code tmux attach -t main`으로 들어가 바로 볼 수 있다.

### 끊겨도 다시 살아나게

Remote Control 서버 모드는 네트워크가 10분 정도 끊기면 스스로 종료된다. 집 인터넷이 잠깐 나갔다 들어오는 일은 흔하니, 루프로 감싸 다시 띄운다.

```bash
#!/bin/bash
# Remote Control 서버를 계속 살려 둔다.
# 서버 모드는 네트워크가 ~10분 끊기면 종료되므로 루프로 재시작한다.
DIR=/workspace/claude-server-manage
[ -d "$DIR" ] || DIR=/workspace
cd "$DIR"
while true; do
  echo "[$(date '+%F %T')] claude remote-control 시작 ($DIR)"
  claude remote-control --name sejong-nas
  echo "[$(date '+%F %T')] 종료(코드 $?) — 30초 후 재시작"
  sleep 30
done
```

작업 디렉터리는 NAS 관리 저장소의 클론이다. 그 안의 `CLAUDE.md`에 접속 방법, 서비스 구조, "삭제는 확인받고 할 것" 같은 작업 규칙이 적혀 있어서, 휴대폰에서 연 세션도 PC에서와 똑같은 규칙으로 움직인다.

인증에서 하나 걸린 점이 있다. Remote Control은 `claude auth login`으로 하는 **구독 계정 로그인**에서만 동작한다. API 키나 `setup-token`으로 발급한 장기 토큰으로는 안 된다. 그래서 첫 기동 후 tmux 창에 들어가 직접 로그인했다.

### 컨테이너에서 NAS를 다루는 방법

이 컨테이너는 NAS 안에 있지만 NAS를 직접 만지지는 못한다. Docker 소켓을 마운트하면 쉽겠지만, 그러면 컨테이너가 곧 NAS의 root가 된다. 대신 PC에서 하던 것과 똑같이 SSH로 NAS에 붙게 했다.

- 컨테이너가 처음 뜰 때 만든 SSH 키를 NAS 사용자의 `authorized_keys`에 등록한다.
- 그 키에는 `from="10.250.0.0/28"`을 붙여, 이 컨테이너 네트워크에서 온 접속만 받게 한다.
- NAS에서 비밀번호 없이 쓸 수 있는 sudo는 `docker` 하나뿐이다. 나머지 sudo는 비밀번호가 필요하니, Claude는 그런 명령을 나에게 건넨다.

```yaml
services:
  claude-code:
    build: .
    image: claude-code:local
    container_name: claude-code
    hostname: sejong-nas-claude
    restart: unless-stopped
    tty: true          # tmux를 포그라운드로 돌리기 위해
    stdin_open: true
    init: true
    mem_limit: 4g
    volumes:
      - ./home:/home/claude      # Claude Code·로그인·설정·SSH 키 (비밀값)
      - ./workspace:/workspace   # 저장소 클론
    # 포트 공개 없음 — Remote Control은 아웃바운드 연결만 사용
    labels:
      - com.centurylinklabs.watchtower.enable=false

networks:
  default:
    ipam:
      # NAS authorized_keys의 from="10.250.0.0/28" 제한과 맞춘다 — 바꾸면 함께 수정
      config:
        - subnet: 10.250.0.0/28
```

솔직히 말하면 `docker`를 쓸 수 있다는 건 사실상 root 권한이다. 마음먹으면 호스트 디렉터리를 마운트한 컨테이너를 띄울 수 있다. 그래서 이 방식의 안전장치는 기술보다 운영 쪽에 있다. 키가 쓰일 수 있는 위치를 좁히고, 위험한 작업은 확인받게 규칙을 두고, 모든 변경은 저장소에 기록으로 남긴다.

### 배포하며 걸린 것들

**홈 폴더가 읽기 전용으로 보였다.** Synology 공유폴더 아래에 만든 폴더는 상위의 Windows식 ACL을 상속한다. 컨테이너 안에서는 이게 `r-x`로 보여서 Claude Code 설치가 실패했다. 바인드 마운트할 폴더만 `synoacltool -del`로 ACL을 지우고 일반 권한으로 돌렸다. 배포 스크립트에 넣어 두었다.

**네트워크 대역이 키 제한과 어긋났다.** compose가 자동으로 잡은 대역은 `192.168.240.0/20`이었는데, 키에는 다른 대역을 적어 두었다. 자동 할당은 다시 만들 때마다 바뀔 수 있으니 compose에서 `10.250.0.0/28`로 고정했다. 컨테이너 몇 개면 /28(16개)로 충분하다.

## 브라우저 MCP는 붙였다가 뗐다

블로그 글에 넣을 캡처를 위해 Claude 전용 크로미움 컨테이너와 Playwright MCP도 띄웠다. 내가 웹 화면(KasmVNC)으로 한 번 로그인해 두면, Claude가 같은 브라우저를 조작해 로그인된 화면을 찍는다.

문제는 토큰이었다. Playwright MCP는 페이지를 열거나 클릭할 때마다 페이지의 접근성 트리 전체를 응답에 실어 보낸다. Claude가 화면을 "보기" 위한 정보지만, 캡처 한 장 찍는 데에도 수천 토큰씩 대화에 쌓였다. 그래서 MCP 등록은 해제하고, 같은 MCP 엔드포인트를 curl로 부르는 작은 스크립트를 만들었다. 응답은 버리고 PNG만 남긴다.

```bash
# browser-shot <url> <파일명.png> [가로] [세로] [full]
call "{... \"name\":\"browser_resize\",\"arguments\":{\"width\":$W,\"height\":$H}}}"
call "{... \"name\":\"browser_navigate\",\"arguments\":{\"url\":\"$URL\"}}}"
sleep 2
call "{... \"name\":\"browser_take_screenshot\",\"arguments\":{\"filename\":\"/captures/$OUT\"}}}"
```

MCP는 Claude가 직접 붙어야만 쓸 수 있는 게 아니다. 결국 HTTP 위의 JSON-RPC라서, 응답이 필요 없을 때는 스크립트에서 불러도 된다. 무엇을 MCP로 연결하고 무엇을 스크립트로 둘지는 응답 크기를 보고 정하게 됐다.

## 지금 쓰는 모습

휴대폰 Claude 앱의 세션 목록에 `sejong-nas`가 떠 있다. 들어가서 하는 일은 대체로 이런 것들이다.

- "컨테이너 상태 점검해 줘" — 재시작 루프나 unhealthy가 있는지 훑어본다.
- "Jenkins 마지막 빌드 왜 실패했어?" — Jenkins MCP로 로그를 검색해 원인 부분만 가져온다.
- "오늘 밤 9시 방송 녹화 대기 걸어 줘" — NAS의 녹화 컨테이너에 명령을 넣는다.
- "이 작업 README에 기록하고 커밋해 줘" — Gitea에 바로 푸시한다.

이 글의 초안도 그 세션에서 썼다. 저장소의 작업 기록과 설정 파일을 읽어 정리하고, Ghost Admin API로 임시저장까지 올렸다. 발행 버튼은 내가 누른다.

## 정리

| 무엇         | 어떻게                                     |
| ---------- | --------------------------------------- |
| Jenkins 연결 | Jenkins MCP 플러그인, 전용 계정 토큰              |
| Gitea 연결   | HTTP 모드 MCP 컨테이너, 토큰은 클라이언트 헤더로, LAN 전용 |
| 상주 실행      | NAS 컨테이너 + tmux + Remote Control 재시작 루프 |
| NAS 조작     | SSH 키(출발지 대역 제한) + docker만 NOPASSWD     |
| 캡처         | 브라우저 MCP는 스크립트로만 호출                     |

AI에게 서버를 맡긴다고 하면 거창하게 들리지만, 해 보니 대부분은 예전부터 하던 인프라 일이었다. 토큰을 어디에 둘지, 어떤 포트를 열지, 권한을 얼마나 줄지, 끊겼을 때 어떻게 살릴지. 그걸 정해 두고 나니 Claude는 그 틀 안에서 꽤 믿을 만한 동료가 됐다.