Skip to main content
Glama
README.md
# Waple 업무일지 MCP 서버

개발자가 하루 동안 수행한 작업을 **Git 기록과 Claude Code 세션 로그에서 자동으로 수집**하여
업무일지 초안을 만들고, **사용자가 승인한 내용만** HR SaaS 플랫폼(Waple)에 등록하는 MCP 서버입니다.

8주 인턴 팀 프로젝트(4인) 중 **MCP 서버 파트를 담당**하여 개발하였습니다.
코드 리뷰는 팀원이 수행하였으며, PR #13에서 지적받은 인증 처리 결함을 반영해 구조를 수정하였습니다.

---

## 1. 왜 만들었는가

업무일지는 매일 써야 하지만, 하루가 끝날 무렵에는 무엇을 했는지 정확히 기억나지 않습니다.
그 결과 "개발 진행함" 같은 내용 없는 기록이 쌓이고, 인사 데이터로서의 가치를 잃습니다.

이미 커밋 메시지와 변경 파일에 하루의 기록이 남아 있는데도 다시 손으로 쓰고 있다는 점에 주목하였습니다.
그래서 **이미 존재하는 근거를 모아 초안을 만들고, 사람은 확인만 하는** 방향으로 설계하였습니다.

---

## 2. 시연

**로컬 시연 (3분 51초)** — 초안 생성부터 승인·등록까지 전체 흐름

https://github.com/user-attachments/assets/11a2e83e-085b-435b-8d98-cd3bad4a6dc9

**등록 결과 (1분 43초)** — Waple 웹 화면에서 등록된 업무일지 확인

https://github.com/user-attachments/assets/4606369c-0ba7-4db5-b42f-f2e1ab72ff88

### 실제 생성된 초안 (2026-07-25)

```
오늘 한 일
- test_connector.py 의 import 문을 파일 상단으로 정리하였습니다. [커밋 97a822d9]
- 포트폴리오용 시연 자료를 촬영하였습니다. [사용자 메모]
- 오늘 사용한 토큰: 세션 로그에서 자동 집계[Claude Code 세션 로그]
```

각 항목 끝의 대괄호가 **작성 근거**입니다.
커밋에서 나온 내용인지, 사용자가 직접 적은 메모인지, 세션 로그에서 집계한 값인지 구분됩니다.
근거가 없는 내용은 지어내지 않고 "사용자 확인 필요" 항목으로 분리합니다.

---

## 3. 동작 구조

```
[Claude Code / Claude 데스크톱 앱]
            │  MCP 프로토콜
            ▼
    ┌───────────────────┐
    │   MCP 서버        │
    │  (server.py)      │
    ├───────────────────┤
    │ 근거 수집          │ ← git log / git diff / 세션 로그(JSONL)
    │ 업무 단위 분류     │
    │ 초안 생성          │
    │ 승인 확인          │ ← 여기서 멈춤. 승인 전에는 등록하지 않음
    │ Waple API 호출     │ ──▶ POST /api/mcp/diary
    └───────────────────┘
```

### 이중 transport 구조

| 방식 | 용도 | 인증 |
| --- | --- | --- |
| **stdio** | 로컬 Claude Code | `.env` 파일의 키 |
| **Streamable HTTP** | 원격 접속 (nginx + HTTPS) | 요청 헤더 `x-api-key` |

HTTP 하나로 통일하지 못한 이유가 있습니다.
Git 기록과 세션 로그는 **사용자 PC에 있는** 자료입니다. 원격 서버는 이 자료에
접근할 수 없으므로, 자동 수집 기능을 유지하려면 로컬 경로가 함께 필요하였습니다.

이 구분이 실제로 얼마나 중요한지는 프로젝트가 끝난 뒤에야 드러났습니다(9절 참고).
원격의 `tasklog` 는 "아무것도 읽지 못하는" 상태가 아니라 **서버 자신의 저장소를
읽는** 상태였고, 그래서 지금은 HTTP 모드에서 호출을 차단합니다.

---

## 4. 제공 도구

| 도구 | 역할 |
| --- | --- |
| `waple_login` | API 키 검증 |
| `tasklog` | Git 기록 기반 초안 생성 (로컬 전용, 원격 호출은 차단) |
| `chat_tasklog` | 대화 내용 기반 초안 생성 (원격 지원) |
| `submit_worklog` | **승인된 초안만** Waple에 등록 |

초안 생성과 등록을 **의도적으로 다른 도구로 분리**하였습니다.
하나로 합치면 "정리해줘"라는 말에도 등록이 실행될 수 있기 때문입니다.

---

## 5. 설계 시 신경 쓴 부분

### 승인 없이는 등록하지 않는다

`정리해줘`, `초안 만들어줘`, `검토해줘` 는 승인으로 보지 않습니다.
`등록해`, `승인`, `이 내용으로 올려` 처럼 명확한 표현이 있을 때만 API를 호출합니다.

### 요청별 키 격리

원격 모드에서는 여러 사용자가 같은 서버 프로세스를 공유합니다.
`ContextVar` 로 요청마다 키를 분리하고, 초안 캐시도 키의 해시값으로 사용자별 스코핑하여
**다른 사용자의 초안이 섞이지 않도록** 하였습니다.

### HTTP 모드에서는 키를 저장하지 않는다

`.env` 저장 로직은 stdio 모드에서만 동작합니다.
원격 사용자의 키가 서버 파일에 남는 것을 막기 위함입니다.

---

## 6. 빠른 시작

```bash
git clone https://github.com/psy0635-ctrl/waple-worklog-mcp-portfolio.git
cd waple-worklog-mcp-portfolio

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install -r requirements.txt

cp .env.example .env             # 실제 값을 채워 넣습니다
```

**로컬 실행 (Claude Code)**

```bash
cp .mcp.json.example .mcp.json
claude                           # 실행 후 /mcp 로 연결 확인
```

**원격 실행 (Streamable HTTP)**

```bash
python server.py --transport streamable-http --port 8010
```

```bash
claude mcp add --transport http waple-remote \
  https://[SERVER_URL]/llm/mcp --header "x-api-key: 발급받은키"
```

자세한 배포·연동 절차는 [docs/integration-manual.md](docs/integration-manual.md) 를 참고하십시오.

---

## 7. 테스트

```bash
python -m pytest -q
```

```
70 passed, 1 skipped
```

`skipped` 1건은 로그 폴더 대소문자 혼재 케이스입니다.
Windows 파일시스템에서는 대소문자만 다른 폴더를 동시에 만들 수 없어 건너뛰고,
리눅스에서 별도로 실행해 통과를 확인하였습니다.

| 파일 | 검증 내용 |
| --- | --- |
| `test_connector.py` | 원격 커넥터, 사용자별 초안 스코핑, 원격 `tasklog` 차단 |
| `test_cache.py` | 초안 캐시 동작 |
| `test_guideline17.py` | 승인 절차·필수값 누락·중복 등록 방지 |
| `test_login_retry.py` | 인증 실패 시 재시도 안내 |
| `test_token_usage.py` | 세션 로그 기반 토큰 집계 |
| `test_draft_cleanup.py` | 초안 캐시 누적 방지 (등록 후 삭제, TTL 정리) |
| `test_multiline_input.py` | 여러 줄 입력 시 불릿·근거 라벨 정규화 (`_format_bullet_lines`) |

---

## 8. 검증 현황

사실과 추정을 구분하기 위해 항목마다 검증 수준을 표기하였습니다.

| 항목 | 결과 | 검증 수준 |
| --- | --- | --- |
| 로컬(stdio) 전체 흐름 | 초안 생성 → 승인 → Waple 등록 성공 | 실물 검증 |
| 원격(HTTP) 연동 | Claude Code 에서 호출, 서버 로그에 기록 확인 | 실물 검증 |
| 자동 재시작 | 프로세스 강제 종료 후 PID 변경 확인 | 실물 검증 |
| 재부팅 후 자동 기동 | `systemctl --user is-enabled`, `Linger=yes` 확인 | 설정 확인 (재부팅 미실시) |
| claude.ai 웹 커넥터 | 연결·도구 목록·도구 호출은 성공, 인증은 불가 (헤더 지정 수단 없음) | 실물 검증 (직접 호출 + 설정 화면 확인) |
| 원격 `tasklog` 차단 | 배포 서버에서 호출 → 차단 응답 반환, 서버 로그에 호출 기록 | 실물 검증 (9절) |

웹 커넥터가 되지 않는 이유는 추정이 아니라 서버 로그로 확인하였습니다.
5일간(2026.07.21 ~ 07.25) 누적된 요청을 발신 경로별로 대조한 결과입니다.

| 발신 경로 | 도구 목록 조회 | 도구 호출 |
| --- | --- | --- |
| claude.ai 웹 커넥터 | 19건 | **0건** |
| 설정 파일 기반 클라이언트 (Claude Code, 데스크톱 앱) | 26건 | 12건 |

웹 커넥터 쪽 응답 코드는 200과 202뿐이고 4xx·5xx는 없었습니다.
연결·TLS·프록시·목록 조회까지는 정상이었고, 이 기간에는 도구 호출이 성립하지 않았습니다.

이후 2026-07-28 에 **웹 커넥터로 도구를 직접 호출해 서버 응답을 받았습니다.**
관찰에서 추론하던 원인을 실물로 확정한 것입니다.

| 시점 | 도구 목록 조회 | 도구 호출 | 인증 |
| --- | --- | --- | --- |
| 2026.07.21 ~ 07.25 | 19건 | 0건 | (판단 불가) |
| 2026.07.28 | 정상 (4개 인식) | **성공** | **실패** |

07.28 호출 시 서버가 반환한 것은 "요청에 `x-api-key` 헤더가 없다"는 안내였습니다.
즉 **연결과 도구 호출은 되지만 인증 수단이 없어 실사용이 불가능한 상태**입니다.

claude.ai 웹 커넥터의 설정 화면에서 제공되는 인증 항목은 고급 설정을 펼쳐도
OAuth 클라이언트 ID·시크릿 두 가지뿐이며, 임의의 요청 헤더를 지정하는 입력란은
제공되지 않습니다(2026-07-28 화면 확인). 이 서버는 `x-api-key` 헤더로 인증하므로
웹 커넥터 경로로는 인증이 성립하지 않습니다.

설정 파일 기반 클라이언트(Claude Code `--header`, 데스크톱 앱 `mcp-remote` 브리지)는
헤더를 지정할 수 있어 정상 동작합니다.

> **검토했으나 원인이 아니었던 것:** FastMCP 의 DNS Rebinding 방어가 Origin 헤더를
> 검증해 `https://claude.ai` 를 403 으로 거부할 가능성을 확인하였습니다. 그러나 위
> 로그에서 웹 커넥터 요청의 응답 코드는 200·202 뿐이고 403 이 한 건도 없었으므로,
> Origin 차단은 실제로 발생하지 않았습니다. 현재 허용 목록에 해당 Origin 이
> 포함되어 있으나 이는 버그 수정이 아니라 예방적 조치입니다.
>
> `webconnector_0728_evidence_masked.txt` 의 139행에 403 이 한 건 있으나 이는
> Origin 차단 동작을 확인하기 위해 직접 실행한 curl 요청이며, 웹 커넥터 발신
> 요청(160.79.106.x)의 응답은 200·202 뿐입니다. 차단 기능은 정상 동작하되 웹
> 커넥터 실패의 원인은 아니었음을 같은 파일에서 확인할 수 있습니다.

마스킹한 로그 발췌는 두 파일로 나누어 두었습니다.

- [docs/evidence/webconnector_evidence_masked.txt](docs/evidence/webconnector_evidence_masked.txt) — 2026.07.21~07.25 구간, 웹 커넥터 발신 요청 0건 확인
- [docs/evidence/webconnector_0728_evidence_masked.txt](docs/evidence/webconnector_0728_evidence_masked.txt) — 2026.07.28 구간, 도구 호출 도달 및 응답 코드 기록

---

## 9. 막혔던 부분

| 문제 | 원인 | 해결 |
| --- | --- | --- |
| 원격 접속 시 421 반환 | FastMCP 의 DNS Rebinding 방지로 허용 Host 가 `127.0.0.1` 로 잠김 | 우선 nginx 에서 Host 헤더를 재작성해 우회. 이후 `transport_security` 로 허용 Host·Origin 을 코드에 명시 (아래 참고) |
| 커넥터 연결 실패 | HTTPS 인증서 체인에 중간 인증서 누락 | 브라우저는 자동 보완되지만 서버 간 통신은 실패함을 확인, fullchain 적용 |
| 서버 재부팅 후 중단 | `nohup` 프로세스가 재부팅과 함께 소멸 | sudo 권한이 제한된 환경이라 사용자 systemd + linger 로 해결 |
| API 키 터미널 노출 | 키 확인 과정에서 값이 평문 출력됨 | 키 삭제 후 재발급. 이후 키를 다루는 명령은 값이 표시되지 않는 방식으로 변경 |

### 프로젝트 종료 후 개선 (2026-07-28)

421 은 nginx 우회로 이미 해결된 상태였지만, 근본 원인을 다시 확인한 결과
**SDK 가 방어를 켜주는 조건이 실행 옵션에 달려 있다**는 점을 발견하였습니다.

FastMCP 는 생성자의 `host` 가 `127.0.0.1`/`localhost`/`::1` 일 때만 DNS Rebinding
방어를 자동 활성화합니다. 그런데 이 서버의 실행 인자 도움말은 "외부 공개 배포 시
0.0.0.0" 을 안내하고 있었습니다. 안내대로 실행하면 보안 기능이 경고 한 줄 없이
꺼지는 구조였습니다.

허용 Host·Origin 을 코드에 직접 명시해 실행 옵션·SDK 버전과 무관하게 동일한 설정이
적용되도록 수정하였습니다. 배포 서버에서 적용 전후를 측정한 결과입니다.

| 측정 조건 | before | after |
| --- | --- | --- |
| 정상 요청 | 406 | 406 |
| 위조 Host | 421 | 421 (방어 유지) |
| 도메인 Host | 421 | 406 |
| `Origin: https://claude.ai` | 403 | 406 |
| 외부 HTTPS 경유 | 406 | 406 |

허용 목록을 SDK 자동 기본값의 상위집합으로 구성하여, 기존에 동작하던 경로가
막히지 않도록 하였습니다. 적용 후 MCP 핸드셰이크와 도구 호출까지 실측하였습니다.
nginx 의 Host 재작성은 이중 안전망으로 그대로 유지하였습니다.

### 프로젝트 종료 후 개선 (2026-07-29) — 내가 남긴 기록이 틀렸던 사례

원격 환경에서 `tasklog` 를 호출하면 어떻게 되는지, 문서에는 이렇게 적혀 있었습니다.

> 초안 자체는 생성되지만 Git 커밋·변경 파일·토큰 사용량이 모두 비어 있습니다. (2026-07-21 기록)

원격 서버가 사용자 PC 에 접근할 수 없으니 당연히 비어 있으리라 여겼고,
실제로 비어 있는 초안을 한 번 보고 그대로 기록하였습니다.
그러나 실사용 중 다시 확인해 보니 **초안에 커밋이 채워져 있었습니다.**
제가 그날 하지 않은 작업이었습니다.

원인은 서비스 설정 한 줄에 있었습니다.

```
WorkingDirectory=%h/2026-uxis-mirae/llm팀
```

`tasklog` 는 `git rev-parse --show-toplevel` 로 저장소 루트를 찾는데, 이 명령이
기준으로 삼는 것은 **프로세스가 실행 중인 디렉터리**입니다. 원격 모드에서 그
디렉터리는 사용자의 작업 폴더가 아니라 배포 서버가 체크아웃해 둔 저장소였습니다.
즉 원격 사용자의 업무일지에 **배포 서버의 커밋 이력이 실리는** 구조였습니다.
토큰 집계도 같은 이유로 서버 계정의 `~/.claude` 를 읽고 있었습니다.

이 문제가 위험한 이유는 **틀린 결과가 정상처럼 보인다**는 점입니다.
수집이 비어 있으면 사용자가 이상을 눈치채지만, 그럴듯한 커밋 목록이 채워져 있으면
그대로 승인하게 됩니다. 이 프로젝트가 처음부터 지켜 온 원칙
— 확인되지 않은 내용을 사실처럼 쓰지 않는다 — 이 정면으로 깨지는 경로였습니다.

수정은 수집이 시작되기 **이전**에 차단하는 방식으로 하였습니다.
요청이 HTTP 인지 판별하는 값(`ContextVar`)은 사용자별 키 격리를 위해 이미
만들어 둔 것이라, 도구 진입점 한 곳에서 조기 반환하는 것으로 충분하였습니다.
조기 반환이므로 초안 캐시가 오염되지 않아, 등록 도구가 잘못된 초안을 집어갈
경로도 함께 사라집니다.

| 확인 항목 | 결과 |
| --- | --- |
| 원인 | `WorkingDirectory` 가 배포 서버 저장소를 가리킴 (설정 실측) |
| 차단 동작 | 배포 서버에 도구 호출 → 차단 응답 반환, 서버 로그에 호출 기록 |
| 초안 캐시 | 조기 반환으로 미오염 (단위 테스트) |
| 로컬 경로 회귀 | 기존대로 초안 생성 (단위 테스트) |
| 테스트 | 3케이스 추가, 22 → 25 passed |

함께 정정한 것은 코드만이 아닙니다.
"수집 항목이 비어 있다" 는 서술이 README·연동 매뉴얼 여러 곳에 퍼져 있었고,
그 문장들을 모두 찾아 실제 동작으로 고쳤습니다. 잘못된 관찰 하나가 문서 전체로
번지면 나중에는 어디까지가 사실인지 알 수 없게 된다는 것을 확인한 사례입니다.

### 프로젝트 종료 후 개선 (2026-07-29) — 막다른 길을 알려주던 안내

인증에 실패했을 때 서버가 돌려주던 안내는 이랬습니다.

> 커넥터 설정 → Request Headers → x-api-key 항목에 Waple API 키를 입력해 주세요.

그런데 8절에 적은 대로, claude.ai 웹 커넥터에는 **그 입력란이 없습니다.**
인증에 실패한 사용자에게 존재하지 않는 화면을 찾아가라고 안내하고 있었던 것입니다.

실제로 헤더를 지정할 수 있는 세 경로(Claude Code `--header`, 데스크톱 앱
`mcp-remote` 브리지, 로컬 stdio)와 웹 커넥터로는 불가능하다는 사실을 함께
안내하도록 교체하였습니다.

고치면서 원인을 하나 더 확인하였습니다. 같은 문구가 `waple_login` 과
`submit_worklog` 두 곳에 복사되어 있었습니다. 한쪽만 고치면 두 안내가 서로
다른 말을 하게 되므로, 상수로 분리하고 **두 도구가 같은 상수를 참조하는지**를
테스트로 고정하였습니다. 문구 자체보다 이 중복이 재발 지점이었습니다.

배포 서버에 키 없이 등록 도구를 호출해 새 안내가 반환되는 것까지 확인하였습니다.

### 프로젝트 종료 후 개선 (2026-07-30)

`tomorrow_plan`·`memo`·`activities`·`created_files`에 개행이 포함된 값을
넣으면, "- "와 근거 라벨이 조립된 문자열의 첫 줄 또는 마지막 줄에만 붙고
중간 줄은 아무 표시 없이 남는 문제를 확인하였습니다. `tomorrow_plan`은
글머리 기호만 깨지지만, `memo`·`activities`는 라벨이 사라진 줄이 **Git에서
확인된 사실처럼 보이게** 되어, 확인되지 않은 내용을 사실처럼 쓰지 않는다는
이 프로젝트의 원칙을 정면으로 어기는 경로였습니다.

원인은 같은 조립 로직(`f"- {값} [라벨]"`)이 두 함수에 복사되어 있었던
것입니다. `_format_bullet_lines` 헬퍼로 로직을 통합해, 값을 줄 단위로
분해한 뒤 각 줄마다 독립적으로 "- "와 근거 라벨을 붙이도록 수정하였습니다.

수정 전(`ad8ce45`)과 수정 후(`47e38ba`) 상태에서 같은 재현 스크립트를
실행한 결과를 한 파일에서 대조할 수 있도록 근거로 남겼습니다
([docs/evidence/multiline_input_evidence.txt](docs/evidence/multiline_input_evidence.txt)).
이 근거 파일을 처음 만드는 과정에서 셸을 혼용해 캡처에 실패한 사고가
있었는데, 그 경위는 [docs/development-notes.md](docs/development-notes.md)
13절 "기록 무결성"에 정리하였습니다.

| 확인 항목 | 결과 |
| --- | --- |
| 원인 | 조립 로직 복붙 (코드 확인) |
| 재현 | 수정 전(`ad8ce45`) 상태에서 실측 재현 |
| 수정 | `_format_bullet_lines` 헬퍼 통합 |
| 테스트 | 13케이스 추가, 28 → 41 passed |

(부수 정정) 이 정리 과정에서 위 7절의 테스트 총계가 실제로는 이미 7/29에
28이었어야 하는데 25로 방치되어 있던 것을 확인하여, 41로 갱신하며 함께
정정하였습니다(`test_connector.py` import 정리 항목도 이미 완료 상태로
남아 있던 것을 development-notes.md에서 함께 정리하였습니다).

---

마지막 항목이 가장 뼈아팠습니다.
키를 재발급하기만 해서는 기존 키가 살아 있다는 점을 뒤늦게 알았고,
**반드시 기존 키를 삭제해야 무효화된다**는 것을 확인하였습니다.

각 사례의 판단 과정은 [docs/development-notes.md](docs/development-notes.md) 에 정리해 두었습니다.

---

## 10. 기술 스택

Python · MCP Python SDK (FastMCP) · requests · pytest
nginx (리버스 프록시, HTTPS) · systemd (사용자 서비스)

---

## 11. 문서

| 문서 | 내용 |
| --- | --- |
| [docs/waple-worklog-mcp-상세설명.pdf](docs/waple-worklog-mcp-상세설명.pdf) | 프로젝트 전체 과정 상세 설명 (인쇄용) |
| [docs/integration-manual.md](docs/integration-manual.md) | 설치·배포·연동 절차 |
| [docs/connector-design.md](docs/connector-design.md) | 근거 수집 설계 |
| [docs/development-notes.md](docs/development-notes.md) | 개발 기록, 오류 사례, 설계 편차와 근거 |
| [deploy/README.md](deploy/README.md) | systemd 운영 가이드 |
| [docs/evidence/](docs/evidence/) | 웹 커넥터 검증에 사용한 서버 로그 발췌 (마스킹) |

---

## 12. 남은 과제

- 원격 모드에서는 Git 기록·토큰 사용량 자동 수집이 원리상 불가능한 구조적 한계
  (현재는 차단 후 `chat_tasklog` 로 안내하며, 대체 수집 경로는 없음)

---

> 회사 도메인·서버 주소 등 인프라 정보는 `[SERVER_URL]` 형태의 플레이스홀더로 대체하였습니다.
> 서비스 포트는 nginx 설정과 배포 구조를 설명하는 데 필요하여 그대로 두었습니다. 도메인·주소가 가려져 있어 포트만으로는 접근 경로가 성립하지 않습니다.