file-analysis
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/hero-dark.svg">
<img src="docs/assets/hero-light.svg" alt="파일 분석 MCP — 문서에서 앵커를 뽑고, 요약은 호스트 모델이 쓰고, 서버가 대조하고, 사람이 승인합니다." width="100%">
</picture>
</p>
<p align="center">
<img alt="Python 3.11+" src="https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white">
<img alt="MCP FastMCP" src="https://img.shields.io/badge/MCP-FastMCP-6E56CF">
<img alt="tools 9, write 1" src="https://img.shields.io/badge/tools-9%20(write%201)-0969DA">
<img alt="tests 162" src="https://img.shields.io/badge/tests-162%20passed-1A7F37">
<img alt="harness checks 17" src="https://img.shields.io/badge/harness%20checks-17-1A7F37">
<img alt="local only" src="https://img.shields.io/badge/access-local%20only-9A6700">
</p>
지정한 폴더의 비정형 문서(`pdf` `docx` `pptx` `svg` `png`)를 읽어, 주요 내용 요약과
파일 구조 분석을 돕는 개인용 로컬 MCP입니다. Claude Code · Codex · Claude Desktop에
붙습니다.
| 문서 | 담는 것 |
| --- | --- |
| **README.md** (이 문서) | 어떻게 쓰는지 |
| [AGENTS.md](AGENTS.md) | **무엇을 만드는지** — 데이터 계약 · 도구 계약 · 가드레일 · 영구 거부 목록 |
| [CLAUDE.md](CLAUDE.md) | 코딩 에이전트 작업 절차 — 워크플로 · 리뷰 체크리스트 · 자주 하는 실수 |
같은 사실이 두 곳에 있으면 `AGENTS.md`가 원본입니다.
---
## 이 서버는 요약하지 않습니다
가장 중요한 설계 결정입니다.
| 계층 | 하는 일 |
| --- | --- |
| **MCP 서버** | 추출 · 구조 분석 · **근거 앵커 부착** · 요약 대조 검증 |
| **호스트 모델** (Claude Code / Codex) | 요약 작성 — 앵커를 인용하면서 |
| **사람** | 승인 |
서버가 요약까지 하면 서버가 자기 API 키로 모델을 또 불러야 하고, 호스트는 요약
결과만 받아서 **근거를 대조할 수 없게** 됩니다. 틀린 요약이 조용히 통과하는
경로가 열립니다. 그래서 서버는 원문과 앵커만 내놓습니다.
---
## 빠른 시작
필수 환경: Python 3.11 이상, [`uv`](https://docs.astral.sh/uv/)
```bash
uv sync --extra dev
```
```bash
uv run python scripts/make_samples.py
```
```bash
uv run python scripts/smoke_stdio.py
```
`smoke_stdio.py`가 `PASS`를 내면 서버가 정상입니다 — 실제 MCP 프로토콜로 서버를
띄워 하네스 규약 17개를 검사하고, `DISCOVER`부터 `SAVED`까지 한 바퀴 돌립니다.
MCP Inspector로 도구를 눈으로 확인하려면:
```bash
uv run mcp dev src/file_mcp/server.py
```
### 분석할 폴더 지정
`config/roots.toml`의 `allowed_roots`를 고칩니다. **이 파일이 서버의 보안 경계입니다.**
```toml
allowed_roots = [
"data/samples",
"C:/Users/<사용자>/Desktop/분석대상",
]
```
`C:/Users/<사용자>` 처럼 상위 폴더를 통째로 넣지 마세요 — 가드가 사실상 없는 것과
같아집니다. 서버는 이 목록 밖의 경로를 어떤 경우에도 열지 않습니다.
### 호스트 연결
**Claude Code**
```bash
claude mcp add file-analysis -- uv --directory "<이-저장소를-클론한-절대경로>" run python src/file_mcp/server.py
```
**Codex** — `~/.codex/config.toml`에 [`config/codex-config.example.toml`](config/codex-config.example.toml)
내용을 붙입니다.
**Claude Desktop** — [`config/claude_desktop_config.example.json`](config/claude_desktop_config.example.json) 참고.
---
## 파이프라인
```mermaid
flowchart LR
S["scan_folder<br/><i>추정 등급 B?</i>"] --> I["inspect_document<br/><i>확정 등급 A/B/C</i>"]
I --> P["build_analysis_prompt<br/><i>앵커 붙은 원문</i>"]
P --> D(["초안 작성<br/><i>호스트 모델</i>"])
D --> G["check_summary_grounding<br/><i>GR-01 … GR-04</i>"]
G --> V["preview_save_report<br/><i>승인 토큰 발급</i>"]
V --> H{{"사람의 승인"}}
H --> W["save_approved_report<br/><i>유일한 쓰기</i>"]
classDef server fill:#ddf4ff,stroke:#54aeff,color:#1f2328
classDef notserver fill:#ffffff,stroke:#afb8c1,stroke-dasharray:5 4,color:#656d76
classDef write fill:#fff8c5,stroke:#d4a72c,color:#1f2328
class S,I,P,G,V server
class D,H notserver
class W write
```
점선은 **서버가 하지 않는 일**입니다. 초안은 호스트 모델이 쓰고, 승인은 사람이 합니다.
| 단계 | Tool | 읽기/쓰기 |
| --- | --- | --- |
| DISCOVER | `list_allowed_roots` | 읽기 |
| DISCOVER | `scan_folder` | 읽기 |
| INSPECT | `inspect_document` | 읽기 |
| READ | `read_document` | 읽기 |
| READ | `read_document_image` | 읽기 |
| DRAFT | `build_analysis_prompt` | 읽기 |
| CHECK | `check_summary_grounding` | 읽기 |
| PREVIEW | `preview_save_report` | 읽기 |
| APPROVE | (사람) | — |
| SAVED | `save_approved_report` | **쓰기** |
**쓰기 도구는 `save_approved_report` 하나뿐입니다.** 승인 토큰 없이는 쓰지 않습니다.
`scripts/smoke_stdio.py`가 쓰기 도구 목록을 검사하므로, 도구를 더 추가하면 스모크
테스트를 함께 고쳐야 합니다.
---
## 등급은 확장자가 아니라 내용으로 갈립니다
```mermaid
flowchart TD
X["파일"] --> Y{"확장자"}
Y -->|"docx · pptx"| A["<b>등급 A</b><br/>구조까지"]
Y -->|"png"| C1["<b>등급 C</b><br/>이미지 판독"]
Y -->|"pdf"| PQ{"공백 제거 후 페이지 텍스트<br/>8자 이상?"}
Y -->|"svg"| SQ{"내용 있는<br/>text 노드?"}
PQ -->|"있음"| B1["<b>등급 B</b><br/>본문만"]
PQ -->|"없음"| C2["<b>등급 C</b><br/>스캔 PDF"]
SQ -->|"있음"| B2["<b>등급 B</b><br/>본문만"]
SQ -->|"없음"| C3["<b>등급 C</b><br/>그림"]
classDef ga fill:#dafbe1,stroke:#2da44e,color:#1f2328
classDef gb fill:#ddf4ff,stroke:#54aeff,color:#1f2328
classDef gc fill:#fff8c5,stroke:#d4a72c,color:#1f2328
class A ga
class B1,B2 gb
class C1,C2,C3 gc
```
| 등급 | 뜻 | 읽는 방법 |
| --- | --- | --- |
| **A** | 구조까지 추출 (제목 레벨 · 표 · 슬라이드 단위) | `read_document` |
| **B** | 본문 텍스트만 추출 | `read_document` |
| **C** | 텍스트 없음 | `read_document_image` — 호스트 모델의 비전 |
| `B?` | **미확정.** 열어봐야 안다 | `scan_folder`의 응답에만 존재 |
`scan_folder`는 **파일을 열지 않으므로** 등급을 확정할 수 없습니다. `pdf`·`svg`는
`B?`로 남고, `inspect_document`가 열어서 확정합니다. **스캔 결과의 `B?`를 확정된
값으로 읽지 마세요.**
샘플이 이걸 증명하도록 배치되어 있습니다 — `흐름도.svg`는 `text` 노드가 있어 **B**,
`도형만.svg`는 도형뿐이라 **C**입니다. 같은 확장자, 다른 등급.
<details>
<summary><b>등급 C를 OCR 없이 판독하는 방법</b></summary>
<br>
호스트 모델의 비전으로 읽습니다. 추가 의존성이 없고, 한글 정확도가 tesseract보다
낫습니다. 오프라인 배치 처리가 필요해지면 `extract_text_ocr` 도구를 별도로 얹습니다.
**스캔 PDF도 래스터라이저 없이 판독됩니다.** 스캔 페이지는 전체가 하나의 임베드
이미지이므로 `pypdf`로 그 이미지를 꺼내면 됩니다 — `PyMuPDF`(AGPL)도 poppler
바이너리도 필요 없습니다.
벡터로만 그려진 페이지는 꺼낼 수 없고, 그때는 `PDF_PAGE_HAS_NO_IMAGE` 오류가
"사람이 화면 캡처해야 한다"고 알려 줍니다. **조용히 빈 결과를 돌려주지 않습니다.**
</details>
<details>
<summary><b><code>inspect_document</code>는 본문을 돌려주지 않습니다</b></summary>
<br>
목차 · 블록 수 · 글자 수 · 확정 등급과 `estimated_read_calls`(전체를 읽는 데 필요한
호출 횟수)만 줍니다. 300페이지 PDF의 모양을 알려고 본문을 컨텍스트에 붓는 일을
막는 것이 존재 이유입니다.
파일을 여는 비용은 `read_document`와 같습니다 — 절약되는 것은 시간이 아니라
**컨텍스트**입니다.
</details>
---
## 인용 앵커 규약
| 형식 | 앵커 | 뜻 |
| --- | --- | --- |
| `docx` | `L14` | 14번째 블록 (문단 또는 표 행) |
| `pptx` | `s7.2` / `s7n` | 7번 슬라이드 2번째 줄 / 발표자 노트 |
| `pdf` | `p3` | 3페이지 |
| `svg` | `t2` | 2번째 `text` 노드 |
| `png` | (없음) | 텍스트가 없으므로 앵커도 없음 |
형식마다 단위가 다르지만 `read_document`의 인터페이스는 **하나**입니다. 모든 형식을
블록의 1차원 목록으로 평탄화하므로 `start`/`end`만 쓰면 됩니다. 블록 하나가 무엇인지는
응답의 `unit`이 알려 줍니다.
앵커 형식을 바꾸면 `grounding.ANCHOR_PATTERN`과 골든셋을 함께 고쳐야 합니다.
어긋나면 멀쩡한 인용이 `GR-02`로 전부 막힙니다.
---
## 근거 대조가 확인할 수 있는 것과 없는 것
<table>
<tr>
<th width="50%">✅ 기계적으로 확인함</th>
<th width="50%">⚠️ 확인하지 못함</th>
</tr>
<tr valign="top">
<td>
- 문장이 앵커를 인용했는가 — `GR-01`
- 그 앵커가 문서에 있는가 — `GR-02`
- 수치·날짜가 인용 블록의 원문에 있는가 — `GR-03`
- 직접 인용(따옴표 안)이 원문과 같은가 — `GR-04`
</td>
<td>
- 요약이 원문의 뜻을 제대로 옮겼는가
- 중요한 것을 빠뜨렸는가
- 인용한 앵커가 **적절한** 앵커인가
(`GR-05`는 어휘 겹침 힌트일 뿐)
</td>
</tr>
</table>
**통과가 '맞다'는 뜻은 아닙니다.** 응답의 `not_verifiable`이 이 한계를 매번
명시합니다 — 확인할 수 없는 것을 확인한 척하면 사람이 "통과했으니 맞겠지"라고
믿게 되고, 그게 검증이 없는 것보다 위험합니다.
원문을 다듬어 쓰는 것은 정상입니다. 대조는 앵커·수치·직접 인용만 봅니다.
---
## 저장 게이트
`preview_save_report`는 구조(`ST-*`)와 근거(`GR-*`)를 모두 보고, 오류가 하나도 없을
때만 **승인 토큰**을 발급합니다. 토큰은 `sha256(원본 상대경로 + 초안)`이라 초안을 한
글자라도 고치면 무효가 됩니다 — 깨끗한 초안으로 미리보기하고 다른 초안을 저장하는
경로가 막힙니다.
`save_approved_report`는 게이트를 **전부 다시** 확인합니다. 미리보기가 통과했다는
모델의 말을 믿지 않습니다.
| 순서 | 확인 | 실패 시 |
| --- | --- | --- |
| 0 | `output_root`가 분석 root 밖인가 | `OUTPUT_INSIDE_ANALYSIS_ROOT` |
| 1 | 구조 (`ST-*`) | `DRAFT_NOT_CLEAN` |
| 2 | 근거 (`GR-*`) | `DRAFT_NOT_CLEAN` |
| 3 | 승인 토큰 | `APPROVAL_TOKEN_MISMATCH` |
기존 산출물이 있으면 덮어쓰고, **이전 내용의 해시**를 감사 기록에 남깁니다.
감사 기록(`data/outputs/_audit.jsonl`)은 append-only입니다.
---
## 하네스 계층 (CAR)
Control–Agency–Runtime 세 축으로 나눕니다. **변경할 파일이 어느 축인지 먼저
정하세요.** 축이 불분명하면 설계가 틀린 신호입니다.
| 축 | 질문 | 파일 |
| --- | --- | --- |
| **Control** | 무엇을 못 하게 막는가 | `paths.py` · `verify.py` · `grounding.py` · `reports.py` · `config/roots.toml` |
| **Agency** | 모델이 무엇을 어떻게 고르는가 | `harness.py` · `server.py` · `phase4_tools.py` · `extract/` · `scan.py` · `images.py` · `templates/` |
| **Runtime** | 무슨 일이 있었는지 남는가 | `trace.py` · `evals/` · `scripts/` |
축별 상세 계약과 의존 방향은 [AGENTS.md 2장](AGENTS.md)에 있습니다.
**진행 상태(어디까지 읽었나)는 서버가 들지 않습니다.** 모델이 소유하고, 서버는
`next_actions`에 `start=N으로 이어서`만 알려 줍니다. 그래서 서버는 무상태이고,
쓰기 도구가 저장 하나로 유지됩니다.
### 자기검증
응답을 돌려주기 직전에 불변식을 확인하고, 깨지면 **잘못된 답 대신 오류**를 냅니다.
| 검사 | 막는 것 |
| --- | --- |
| 앵커 유일성·비어있지 않음 | 근거 대조가 엉뚱한 블록을 가리키는 것 |
| 본문 줄 ↔ 블록 일치 | 절단이 블록 중간에서 끊겨 근거 대조가 실패하는 것 |
| 집계 합 = 행 수 | 개수를 코드가 세지 않았거나 중복 계산한 것 |
| 등급 ↔ 블록 모순 | 등급 B인데 읽을 블록이 없다고 보고하는 것 |
여기서 걸리는 것은 사용자 입력 문제가 아니라 **서버 버그**입니다. 그래서 오류
메시지도 "파일을 확인하세요"가 아니라 "이건 서버 결함이니 작업을 중단하고
보고하세요"라고 말합니다.
---
## 관찰성
도구 호출 하나가 `data/traces/YYYY-MM-DD.jsonl`에 한 줄로 남습니다.
```bash
uv run python scripts/trace_report.py
```
**남기지 않는 것이 더 중요합니다.** 실제 사내 문서를 분석하면 트레이스가 그 문서의
사본이 될 수 있습니다.
| 규칙 | 강제 방식 |
| --- | --- |
| 본문·발췌·목차 텍스트 없음 | `_sanitize_counters`가 40자 초과 문자열을 버린다 |
| 초안(`draft`) 본문 없음 | `ARG_ALLOWLIST`에 등록하지 않았다 |
| 절대 경로 없음 | `(절대경로)/파일명`으로 접힌다 |
| 오류 `options` 없음 | root 절대경로 목록이 새지 않게 `summary`만 |
규약이 아니라 **코드로 강제하고 테스트로 확인합니다** (`tests/test_trace.py`).
`trace_dir`이 `allowed_roots` 안에 있으면 트레이스가 스스로 꺼집니다 — 분석 대상
폴더를 자기 기록으로 오염시키지 않기 위해서입니다.
<details>
<summary><b><code>readOnlyHint</code>와 트레이스 쓰기는 모순이 아닙니다</b></summary>
<br>
읽기 도구 8개가 모두 `readOnlyHint: True`인데 트레이스는 파일을 씁니다.
그 힌트는 **분석 대상 문서**를 바꾸지 않는다는 뜻입니다. 트레이스는 `allowed_roots`
밖의 계측 로그이고, 도구로 노출되지도 않습니다. 쓰기 도구로 노출되는 것은
`save_approved_report` 하나뿐이고, 스모크 테스트가 이 목록을 검사합니다.
</details>
---
## 평가
```bash
uv run python scripts/eval_extract.py
```
`evals/golden/samples.json`의 기대값과 실제 추출 결과를 대조하고 결과를
`evals/reports/`에 남깁니다. pytest는 "지금 통과하는가"만 알려 주고, 이 리포트는
**언제 무엇이 통과했는지**를 남깁니다.
기대값은 `scripts/make_samples.py`가 파일에 무엇을 넣었는지를 보고 손으로 적은
것입니다. **추출기 출력을 복사한 것이 아닙니다.** 골든셋을 결과에 맞춰 고치면
평가가 자기 자신을 통과시킵니다. 고쳐야 하는 정당한 경우는 앵커 규약·등급 정의·
샘플 내용이 바뀌었을 때뿐입니다.
---
## 의존성
| 패키지 | 라이선스 | 용도 |
| --- | --- | --- |
| [`mcp[cli]`](https://github.com/modelcontextprotocol/python-sdk) | MIT | FastMCP 서버 |
| [`pypdf`](https://github.com/py-pdf/pypdf) | BSD | pdf 텍스트·임베드 이미지 |
| [`python-docx`](https://github.com/python-openxml/python-docx) | MIT | docx |
| [`python-pptx`](https://github.com/scanny/python-pptx) | MIT | pptx |
| [`pillow`](https://github.com/python-pillow/Pillow) | MIT-CMU | png 메타·이미지 축소 |
`svg`는 표준 `xml.etree`로 읽습니다 — 의존성 0.
> **`PyMuPDF`(`fitz`)를 쓰지 않는 이유**: 성능은 더 좋지만 **AGPL-3.0**이라
> 사내 도구에 넣으면 배포 조건이 걸립니다. 표 추출이 실제로 필요해지면
> `pdfplumber`(MIT)를 추가하세요.
---
## 커밋하지 않는 것
| 경로 | 이유 |
| --- | --- |
| `data/samples/` | `scripts/make_samples.py`로 생성합니다 |
| `data/outputs/` | 분석 결과와 감사 기록. **실제 문서의 요약이 들어갑니다** |
| `data/traces/` | 실행 기록. 본문은 없지만 파일명·경로가 남습니다 |
| `evals/reports/` | 로컬 실행 결과. **골든셋은 커밋합니다** |
| `config/roots.local.toml` | 개인 경로 |
**분석 대상 실제 문서를 이 저장소 안에 두지 마세요.**
TDQS
Scored across 9 tools
Each tool has a clearly distinct purpose: listing roots, scanning folders, inspecting documents, reading text, reading images, building prompts, checking grounding, previewing saves, and saving reports. There is no overlap or ambiguity between tools; even closely related tools like read_document and read_document_image are explicitly differentiated by content type (text vs. image).
All tool names follow a consistent verb_noun pattern in snake_case: list_allowed_roots, scan_folder, inspect_document, read_document, read_document_image, build_analysis_prompt, check_summary_grounding, preview_save_report, save_approved_report. The minor compound in preview_save_report still reads predictably, and there is no mixing of conventions.
With 9 tools, the set is well-scoped for a file-analysis and report-generation server. It covers the full pipeline from discovery (list_allowed_roots, scan_folder) through inspection and reading (inspect_document, read_document, read_document_image) to authoring and publishing (build_analysis_prompt, check_summary_grounding, preview_save_report, save_approved_report), with each tool earning its place.
The tool surface provides a complete workflow for analyzing documents and producing grounded reports: it includes discovery, inspection, reading (text and images), prompt building, grounding verification, save preview, and final save. There are no obvious gaps; even edge cases like scanned PDFs are handled via read_document_image, and the save pipeline includes a human-approval gate.