# MCP 로 붙이기 — 에이전트가 NODA 를 도구로 쓴다 (W460)

> HTTP API(`docs/api.md`)와 **같은 일**을 MCP 도구로 내놓는다. 도구마다 HTTP 의 길 하나를 그대로 부르므로 결과의 꼴과 뜻이 같다.
> 에이전트 플랫폼(Claude, 사내 에이전트, MCP 를 받는 업무 플랫폼)은 주소와 키만 있으면 붙는다.
> 제 코드에서 부르거나 RAG 프레임워크에 끼우는 길은 `docs/integrations.md`(W479)에 있다 — 표준 라이브러리 보기 `examples/noda_mcp.py`·`rag_loop.py`.

## 주소와 키

| 항목 | 값 |
|---|---|
| 전송 | Streamable HTTP · JSON-RPC 2.0 · 응답은 `application/json` (SSE 를 열지 않는다 — `GET /mcp` 는 405) |
| 주소 | `https://<서버>/mcp` — 운영 서버는 **`https://api.readlayer.kr/mcp`** (W472) |
| 인증 | `Authorization: Bearer <키>` — HTTP API 와 같은 키. 서비스마다 키를 나눈다 (`NODA_API_KEYS`) |
| 보관함 | **키 이름 = 보관함 이름** (W469) — 키 `workon` 은 보관함 `workon` 과 `workon-…` 만 쓴다. 남의 보관함은 403(도구 오류)이다. 관리용 키(`NODA_API_KEY`)는 다 쓴다 |
| 판 | `2025-06-18` · `2025-03-26` · `2024-11-05` — 부르는 쪽이 말한 판으로 답한다 |
| 출처 | 브라우저가 붙인 `Origin` 의 호스트가 서버와 다르면 403 (DNS 재바인딩 막기). 서버에서 부르는 에이전트는 상관없다 |

## 서비스를 붙이는 순서 (운영자)

새 서비스(예: `workon`) 하나를 붙일 때 이 순서를 그대로 따른다. **서비스마다 키 하나 · 보관함 하나**다.

1. **키를 만든다** — 운영자의 기계에서 `python -c "import secrets; print(secrets.token_urlsafe(32))"`. 키는 채팅·메일·저장소에 안 남긴다.
2. **서버에 등록한다** — Coolify 의 환경변수 `NODA_API_KEYS` 에 JSON 한 장으로 `{"workon": "<키>", "chat": "<키>"}`.
   **이름이 곧 그 서비스의 보관함 이름**이다(영문·숫자·한글·`_`·`-`). 환경변수라 **Redeploy** 해야 읽는다.
   (파일을 걸 수 있는 곳은 `NODA_API_KEYS_FILE` — 요청마다 다시 읽어 재시작이 없다.)
3. **그 서비스에 넘긴다** — 주소 `https://<서버>/mcp` · 키 · 보관함 이름(`workon`, 필요하면 `workon-기안` 처럼 `workon-` 으로 시작하는 이름) · 이 문서.
4. **연결을 확인한다** — 그 키로 `tools/list` → `noda_add_document`(보관함 `workon`) → `noda_get_context` → `noda_verify_answer` → `noda_remove_document`.
   남의 보관함 이름을 주면 도구 오류(403)가 나는지도 한 번 본다.
5. **사용량을 본다** — 관리용 키로 `GET /usage` 를 부르면 서비스마다 길·호출 수·오류·바이트·ms 가 나온다(서비스 키로 부르면 **제 것만** 보인다).
   `NODA_USAGE_LOG` 를 주면 같은 줄이 파일에 남는다 — 과금은 그 파일로 한다. 문서 내용은 한 글자도 안 쓴다.
6. **뗄 때** — `NODA_API_KEYS` 에서 그 이름을 지우고 Redeploy. 보관함 폴더의 문서는 남으므로 관리용 키로 `DELETE /documents` 를 하거나
   `tools/retention.py purge` 로 파싱 캐시까지 지운다.

**막지 않는 것**: 한 보관함 안의 문서 단위 권한은 `ACL.json` 이고 그 주체(`principal`)는 부르는 쪽이 말한다 — 서비스 사이의 벽은 위의 「키 이름 = 보관함」이고,
서비스 안 사용자 사이의 벽은 그 서비스가 세운다. 키 없이 열린 `/health` 는 키가 걸린 서버에서 보관함·서비스 **이름을 안 내고 수만** 낸다.

## 도구

| 도구 | 하는 일 | 바꾸나 |
|---|---|---|
| `noda_read_file` | 파일 하나(base64)를 읽어 **머리의 목차 + 읽는 차례대로의 글 + 마크다운 표**로 준다(목차는 80마디까지 · W468). `format: "json"` 이면 계약의 문서 JSON. 저장하지 않는다 | 아니오 |
| `noda_list_documents` | 보관함(테넌트)에 무엇이 있고 무엇을 못 읽었나 · 확인할 문서 · 서식 칸 | 아니오 |
| `noda_search` | 질의에 맞는 조각 + 인용 근거(파일·쪽·좌표) | 아니오 |
| `noda_get_context` | 질문에 답할 근거 묶음 — `[n]` 번호·파일·쪽 · 글자 예산. 답은 에이전트의 LLM 이 짓는다 | 아니오 |
| `noda_verify_answer` | 지은 답을 근거에 대고 본다 — 근거에 없는 숫자 · 조각에 없는 인용 · 없는 번호를 짚는다 (고쳐 주지 않는다) | 아니오 |
| `noda_add_document` | 보관함에 문서를 넣고 바로 읽어 상태를 준다 (`replace`) | 예 |
| `noda_remove_document` | 보관함에서 문서를 뺀다 — **되돌릴 수 없다** (`destructiveHint`) | 예 |
| `noda_report_problem` | **엔진이 못 읽었거나 읽은 값(제목·발신·수신·문서번호·목차·표)이 틀렸을 때 알린다** (W474) — 문서는 `doc_id`·`filename` 으로 가리키고 본문은 안 보낸다. 업무 판단이 틀린 것은 알리지 않는다 | 적기만 |

**권하는 흐름**: `noda_get_context` 로 근거를 받는다 → 에이전트의 LLM 이 그 글만 보고 `[n]` 으로 인용하며 답한다 →
`noda_verify_answer` 에 답과 `sources` 를 넘긴다 → 짚인 문장을 고치거나 「근거에 없다」고 말한다.
엔진이 문서를 **잘못 읽었으면**(못 읽음 · 서식 칸·목차·표가 원문과 다름) `noda_report_problem` 으로 알린다 — 그것이 엔진이 고쳐지는 길이다.

**못 읽은 파일은 도구 오류가 아니다** — 까닭을 사람 말로 든 답이다 (규칙 6). 요청이 틀렸거나(빠진 값 · 없는 보관함)
동시 파싱 상한이 찼으면(`[503]`) `isError: true` 와 까닭을 준다 — 에이전트가 읽고 다시 부를 수 있게.

## 붙이는 법

**Claude Code**

```bash
claude mcp add --transport http noda https://<서버>/mcp --header "Authorization: Bearer <키>"
```

**MCP 설정 파일을 받는 클라이언트** (원격 HTTP 서버를 지원하는 것)

```json
{"mcpServers": {"noda": {"type": "http", "url": "https://<서버>/mcp",
                         "headers": {"Authorization": "Bearer <키>"}}}}
```

**손으로 확인** — 키는 셸 변수로 넘긴다 (명령 기록에 남기지 않는다)

```bash
curl -s https://<서버>/mcp -H "Authorization: Bearer $NODA_KEY" -H 'Content-Type: application/json' \
     -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

## 한계

- **파일은 base64 로 넘긴다** — 상한은 HTTP 와 같다(`NODA_MAX_UPLOAD` · 기본 100MB). 큰 파일은 HTTP 의 `/documents/upload` 가 낫다.
- **긴 문서의 글은 자른다** — `noda_read_file` 의 글은 6만 자까지이고 잘랐으면 끝에 그렇게 적는다. 전부는 `format: "json"`.
- **알림·진행 상황을 보내지 않는다** — 스캔 여러 쪽은 수십 초가 걸리는데 그동안 아무 말이 없다. 부르는 쪽의 시간 제한을 5분으로 둔다.
- **세션이 없다** — `Mcp-Session-Id` 를 주지 않는다. 요청 하나하나가 따로 선다.
