# a360-mcp 구현 인계 문서

이 문서 하나만 읽고 이어서 구현할 수 있도록 작성했다. 대화 맥락 없이 시작해도 된다.

- **읽는 순서**: 이 문서 → `CLAUDE.md`(규칙) → `src/a360_mcp/auth/`(완성된 참조 구현)
- **작성 시점**: 2026-08-06
- **완료 범위**: 설정 모델 + 인증 계층 (테스트 85개, 커버리지 90%)

---

## 1. 이 프로젝트의 정체

**A360 Control Room의 봇 실패를 사후 진단하는 MCP 서버.**

핵심 질문 하나에 답하는 게 목적이다:

> 봇이 돌다가 죽었다. **어느 액션에서, 왜, 그리고 앞단에 뭐가 빠져 있었나?**

### 하지 않는 것 (중요)

Automation Anywhere는 v.38부터 **MCP Inbound**를 자체 제공한다 (`https://<cr>/mcp`).
그건 봇을 **실행**시킨다. RBAC, 거버넌스 로깅, 자동화별 등록까지 다 있다.
**봇 실행 기능을 여기에 다시 만들지 마라.** 벤더 것을 쓰면 된다.

벤더가 안 하는 것이 이 프로젝트의 범위다:
- 실행 이력 조회 및 실패 분석
- 봇 로직(JSON) 정적 분석
- WLM 큐 / 감사 로그 / 디바이스 상태
- 실행 실패 ↔ 봇 로직 매핑

### 환경

**Control Room은 클라우드다.** 온프렘이 아니다. 따라서 self-signed 인증서 처리는
불필요하다 (`ca_bundle` 옵션은 남겨뒀지만 우선순위 없음).

---

## 2. 현재 상태

```
a360-mcp/
├── pyproject.toml              ✅ hatchling, py3.11+, ruff/black/isort/mypy, coverage 80% 게이트
├── CLAUDE.md                   ✅ 프로젝트 규칙 + 확정된 API 사실
├── IMPLEMENTATION_HANDOFF.md   ✅ 이 문서
├── README.md  LICENSE(Apache-2.0)
├── src/a360_mcp/
│   ├── version.py              ✅
│   ├── utils/
│   │   ├── config.py           ✅ ServerConfig / A360AuthConfig / AuthType
│   │   └── atomic_io.py        ✅ write_json_atomic / read_json
│   └── auth/                   ✅ 전부 완성 — 이후 코드의 스타일 기준으로 삼아라
│       ├── _paths.py           state_dir, account_key, token_path, lock_path
│       ├── _locks.py           O_EXCL 크로스프로세스 락 + 나이 기반 stale 수거
│       ├── _token_store.py     Token(frozen) + JWT exp 파싱 + 디스크 캐시
│       ├── _session_budget.py  세션 5개 상한 회계
│       ├── _http_session.py    세션 팩토리 + 크로스오리진 자격증명 스트립
│       └── auth_manager.py     ★ 토큰 생명주기 전체
└── tests/                      ✅ 85 passed, 90% coverage
    ├── conftest.py             autouse 상태 디렉터리 격리 + 공용 픽스처
    ├── test_config.py  test_locks.py  test_token_store.py
    ├── test_session_budget.py  test_auth_manager.py
```

### 검증 명령

```bash
cd ~/app/a360-mcp
saferun -m 4G .venv/bin/pytest tests/ -q          # ★ pytest는 반드시 saferun으로 감쌀 것
.venv/bin/ruff check src tests
.venv/bin/black src tests && .venv/bin/isort src tests
.venv/bin/mypy src
```

> **`saferun` 없이 pytest를 돌리지 마라.** 이 머신에서 감싸지 않은 pytest가 systemd-oomd를
> 불러 tmux 세션을 세 번 전멸시킨 이력이 있다.

---

## 3. 확정된 A360 API 사실

**이 절의 내용은 공식 OpenAPI 스펙 원문에서 확인한 것이다. 다시 조사하지 마라.**
표시가 없는 항목은 확정, `[미검증]`은 라이브 Control Room에서 확인이 필요한 항목이다.

### 3.1 인증 (구현 완료)

| 항목 | 값 |
|---|---|
| 엔드포인트 | `POST /v2/authentication` — **v1 아님** (v1엔 `/token`, `/logout`, `/app/login`만 존재) |
| 요청 본문 | `{username, apiKey, multipleLogin}` |
| 배타성 | `apiKey`와 `password`는 **동시 사용 불가** (스펙 `anyOf`) |
| 응답 토큰 | 최상위 **`token`** 필드 |
| 인증 헤더 | **`X-Authorization: <token>`** — **`Bearer` 접두사 없음** |
| 금지 | `X-Authorization`과 `Authorization: Bearer`를 함께 보내면 거부됨 |
| 토큰 수명 | 기본 **20분** (`ttlSeconds: "1200"`) |
| 갱신 | `POST /v2/authentication/token` + 현재 토큰 |
| **갱신 부작용** | **갱신하면 기존 토큰이 즉시 죽는다** → 직렬화 필수 |
| 세션 상한 | **계정당 동시 5개**. 6번째 로그인이 기존 세션을 축출 |
| `ttlSeconds` | 최초 인증 응답에서는 `"0"`으로 오며 **무시됨**. 갱신 응답에서만 유효 |
| API Key 발급 | `Generate API-Key` 권한 커스텀 롤 필요 (시스템 롤엔 없음) → My settings에서 생성 |
| API Key 유효기간 | Settings > Security settings > API-Key Duration, 1분 ~ 9999일 |
| 2FA | v2 `AuthRequest`에 **`mfaCode` 필드 없음** → API 계정은 2FA 미적용 롤로 분리 |

### 3.2 진단 경로 (핵심 기능)

```
POST /v3/activity/list                    실패 실행 검색
  └→ GET /v3/activity/execution/{id}      에러 상세
       └→ GET /v2/repository/files/{fileId}/content   봇 로직 JSON
            └→ 매핑 → "N번째 액션에서 왜 죽었나"
```

**`GET /v3/activity/execution/{id}` 응답 스키마 (스펙 원문 확인)**

```jsonc
{
  "id": "...",
  "deploymentId": "...",        // ★ id와 다른 값이다
  "status": "RUN_FAILED",       // enum: RUN_FAILED, RUN_ABORTED, RUN_TIMED_OUT, DEPLOY_FAILED, ...
  "message": "...",             // 사람이 읽는 오류 문자열
  "error": { /* DetailedError */ }   // status == RUN_FAILED 일 때만 채워짐
}
```

스펙 원문:
> `"error": {"$ref": "#/components/schemas/DetailedError", "description": "Detailed, structured
> error information if the execution 'status' is 'RUN_FAILED'. The nested fields will be empty
> if the execution completed successfully."}`

**구현 규칙**: `error` 키가 있는지 **런타임에 확인**하고, 없으면 `message` 문자열 파싱으로
폴백하라. **Control Room 버전으로 분기하지 마라** — `DetailedError`가 도입된 버전이
확정되지 않았고, 어차피 `DetailedError`는 `message`의 구조화 분해본이다.

**`deploymentId` ≠ execution `id`**: 배포 API가 준 `deploymentId`로는 실행을 직접 조회할 수
없다. `/v3/activity/list`에서 `deploymentId`로 찾아 `id`를 얻어야 한다.
`[미검증]` 그 필드가 필터 가능한지는 라이브 확인 필요.

### 3.3 주요 엔드포인트

| 용도 | 메서드 · 경로 | 비고 |
|---|---|---|
| 봇/파일 목록 | `POST /v2/repository/file/list` | 필터/정렬/페이징 |
| 봇 로직 | `GET /v2/repository/files/{fileId}/content` | 봇 JSON 원문 |
| 의존성 | `GET /v2/repository/files/{fileId}/dependencies` | |
| 실행 이력 | `POST /v3/activity/list` | 페이지 최대 10,000 |
| 실행 상세 | `GET /v3/activity/execution/{id}` | |
| 배포 | `POST /v4/automations/deploy` | v3보다 v4 권장 (벤더 명시) |
| 디바이스 | `POST /v2/devices/list` | |
| WLM 큐 | `POST /v3/wlm/queues/list` | |
| WLM 워크아이템 | `POST /v3/wlm/queues/{id}/workitems/list` | |
| 감사 로그 | `POST /v1/audit/messages/list` | **응답당 400행 상한**, offset 페이징 |

**WLM 주의**: 8개 엔드포인트가 v.43부터 deprecated이고 **v.46에서 EoL**이다.
신규 구현은 해당 8개를 v4로 써라 (`/v4/wlm/...`). 개요 문서에 `/v4/wlm/workitemmodel`(단수)로
적혀 있지만 **실제 스펙 경로는 복수형 `/v4/wlm/workitemmodels`** 이다. 스펙을 신뢰하라.

**`AuditMessage`에 `objectType` 필드는 존재하지 않는다.** 이 필드를 전제로 설계하지 마라.

### 3.4 설계를 규정하는 한계

| 한계 | 영향 |
|---|---|
| **클라우드 실행 이력 보존 90일** | 진단 대상이 사라진다 → **로컬 스냅샷 필수** |
| 감사 로그 보존 180일 (일부 리전 365일) | 위와 동일 |
| **클라우드엔 이력→감사로그 백업 경로가 없다** | 온프렘과 다르다. 대안이 없다 |
| 실행 궤적(루프 회차·분기·변수값)은 **어떤 API로도 못 얻는다** | 실패 지점만 알 수 있다 |
| `Log text to file` 산출물은 `FILE_LOCAL` 제약 | **CR/API로 절대 못 가져온다.** 약속하지 마라 |
| UI 라벨 ≠ API enum (`Run failed` vs `RUN_FAILED`) | 매핑 테이블 필요 |

### 3.5 계측 처방 (실제 기능으로 구현할 것)

`Catch` 액션에 다음 두 필드가 있다:
- **`Assign the line number to`** → 실패 라인 번호를 변수로
- **`Assign exception message to`** → 예외 메시지를 변수로

이 둘을 `Log to Server`로 보내면 `executionId` + 라인번호 + 예외메시지가 한 레코드로 묶인다.
스크린샷도 마찬가지로 `Catch` 안에 `Screen Capture`를 넣어야만 남는다 (플랫폼이 자동으로
남기지 않는다).

**따라서 Try/Catch가 없는 봇에 대한 올바른 툴 출력은 "진단 불가"가 아니라 처방이다**:
> "이 봇은 사후 진단이 불가능하다. 다음과 같이 계측하라: …"

### 3.6 벤더 문서 읽는 법

`docs.automationanywhere.com`은 Fluid Topics SPA이고 **Redoc을 iframe 안에** 렌더한다.
일반 fetch로는 본문이 안 나온다 (2,921바이트 셸만 옴).

1. `/r/...` 사람용 페이지에서 시작
2. `/access?ft:originId=<spec>.yaml&ft:sourceId=redoc` 리졸버가 `/h/<pkgId>/...`로 303
3. 그 페이지 DOM의 iframe src가 `htmlpackage` 경로
4. **same-origin으로** fetch해야 본문(수십~수백 KB)이 나온다

패키지 ID는 재배포 시 바뀐다. 인용은 항상 `/r/...` URL로 하고, 패키지 ID는 조회 수단으로만
취급하라.

---

## 4. 남은 구현 — 단계별

각 단계는 **테스트 먼저(RED) → 구현(GREEN) → 리팩터** 순으로 진행한다.
`auth/` 디렉터리가 스타일 기준이다. 새 코드가 그것과 다르게 생겼다면 새 코드가 틀린 것이다.

### Phase 2 — HTTP 데이터 액세스 계층 `tools/a360_api.py`

`AuthManager.make_request` 위에 얹는 공통 데이터 계층. 모든 도메인 툴이 이걸 통해서만
Control Room에 접근한다.

책임:
- **페이지네이션** — A360 목록 API는 `POST .../list` + `{filter, sort, page: {offset, length}}`.
  전체를 다 가져오는 헬퍼와 한 페이지만 가져오는 헬퍼를 분리하라.
- **필터 빌더** — A360 필터는 ServiceNow식 인코딩 쿼리가 아니라 JSON이다:
  `{"filter": {"operator": "and", "operands": [{"operator": "eq", "field": "status", "value": "RUN_FAILED"}]}}`
  `[미검증]` 정확한 연산자 목록은 라이브 응답으로 확인하라.
- **2단 TTL 캐시** — 일반 30초 / 메타데이터 600초. **성공 응답만** 캐시. 쓰기 시 무효화.
- **payload safety** — 유니버설 기본 프로젝션. heavy 필드를 요청하면 `limit`을 5로 강제 클램프.
- **빈 필드 제거** — `None`/`""`/`{}`/`[]`는 버리고 `0`/`False`/`"0"`은 유지.
- **컬럼형 인코딩** — 행이 3개 이상이면 플래그 없이 자동으로
  `{"columns": [...], "data": [[...]]}`. JSON 바이트의 28.7%가 반복 키다.

### Phase 3 — 응답 예산 `utils/response_budget.py`

- **UTF-8 바이트 기준 75KB** 상한 (문자 수 아님)
- 초과 시: ① 식별자를 가진 컨테이너 안의 2KB 이상 문자열을 큰 것부터 이진탐색으로 stub 치환
  → ② 그래도 넘치면 record list를 뒤에서 절단
- stub 마커: `{_abridged, _full_length_bytes, _sha256, _preview(240자), _fetch}`
  — `_fetch`는 **복붙 가능한 재조회 지시문**이어야 한다
- `PROTECTED_KEYS`: 식별/네비 필드 + 안전 신호 + **재fetch 불가 계산값**은 어떤 깊이에서도
  자르지 않는다
- **잘랐으면 잘랐다고 말한다.** `dropped_items` 같은 필드로 명시. 절단을 완전성으로 위장하지 마라.

### Phase 4 — 툴 레지스트리 + 서버 `utils/registry.py`, `server.py`

- `@register_tool(name, params=Model, description=..., ...)` 데코레이터 자동 등록
- **AST 기반 지연 발견** — 정규식 아님(주석/문자열 속 유령 매치 방지).
  사전 생성된 `tools/_module_index.py`를 우선 로드하고 없으면 라이브 스캔 폴백
- 툴 함수 시그니처는 `(config, auth_manager, params)` 3인자 고정
- `list_tools` 파이프라인: 패키지 게이팅 → 액션 allowlist → **스키마 컴팩션**
  - `title` 제거, `anyOf` nullable 평탄화, 모델 docstring 제거
  - **60자 넘는 default 문자열은 자르지 말고 드롭** (잘린 default를 모델이 복붙하면 툴콜이 깨진다)
  - **파라미터 description은 절대 자르지 마라** (라우팅 힌트가 지워져 오라우팅 유발)
- `call_tool` 파이프라인: 별칭 라우팅 → 활성 확인 → 액션 allowlist 재검사 → read-only 검사
  → pre-confirm 가드 → confirm 게이트 → post-confirm 가드 → Pydantic 검증 → 실행
  → 예외를 **구조화 dict로 반환**(raise 아님) → 직렬화 + 응답 예산

**패키지 레이어는 처음부터 린하게.**
`core` → `bot_read` / `log_read` → `diagnose` → `full`.
모두가 상속하는 만능 `standard` 패키지를 만들지 마라 — 자매 프로젝트에서 요청당
2~3천 토큰 세금으로 이어진 알려진 부채다.

### Phase 5 — 정책 게이트 `policies/write_guards.py`

- **쓰기 분류 단일 진실원천** — `MUTATING_TOOL_PREFIXES` + `MUTATING_TOOL_NAMES` +
  `READ_ONLY_ACTIONS`. `server.py`는 이걸 **import** 한다. 손으로 미러링하면 드리프트가 난다.
  동일성을 테스트로 핀 고정하라.
- **확정되지 않은 변이는 네트워크를 0회 건드린다** → 가드를 pre-confirm(로컬 전용)과
  post-confirm(라이브 read 허용)으로 분리
- 가드 슬롯:
  - **G3/G8 동시편집** — 봇/폴더를 다른 사람이 최근 N분 내 수정했으면 차단.
    시각 기준은 **응답의 `Date` 헤더**(서버 시계). 같은 사용자면 절대 안 막음
  - **G7 publish급 이중 확인** — 봇 실행/스케줄 생성은 `confirm='approve'` **위에**
    `confirm_environment='prod'` 추가 요구
  - **G9 동명 생성 차단**
  - **G10 세션 신원 검증** — 라이브 세션 사용자 ≠ 선언된 소유자면 차단
- **fail-open이 기본, `A360_WRITE_GUARDS_FAIL=closed`로 뒤집기 가능.**
  확인하지 못한 것으로 막지 않는다
- **모든 거부는 다음 행동을 준다.** 경로 없는 거부 금지

### Phase 6 — 진단 도메인 (이 프로젝트의 본체)

`services/execution.py`, `services/bot.py`, `tools/execution_tools.py`,
`tools/bot_tools.py`, `tools/diagnose_tools.py`

핵심 툴 `diagnose_execution(execution_id)` 흐름:

1. `GET /v3/activity/execution/{id}` — 실행 상세
2. `status`가 실패 계열이 아니면 즉시 반환 (verdict 한 줄)
3. `error` 키 feature-detect → 없으면 `message` 파싱 폴백
4. 봇 `fileId`로 `GET /v2/repository/files/{fileId}/content`
5. 봇 JSON을 DFS로 훑어 액션 노드 목록 생성 (각 노드의 인덱스/식별자 확보)
6. 에러 정보와 액션 노드 매핑
7. 해당 액션 **앞뒤 컨텍스트** 분석 — 선행 검증 액션이 있는지, 감싸는 Try/Catch가 있는지
8. 봇에 Try/Catch가 없으면 **계측 처방**을 함께 반환 (3.5절)
9. **진단 결과를 로컬 스냅샷 저장소에 기록** (Phase 7)

출력은 **verdict 우선**:
```
verdict: "action 37 'Open Excel' failed: file not found. No existence check upstream,
          no error handler around it."
```
그다음 구조화된 상세, 봇 JSON 원문은 **절대 컨텍스트로 반환하지 않는다** (디스크 + 경로).

### Phase 7 — 스냅샷 저장소 `utils/snapshot_store.py`

**보존 90일 대응. 1일차 요구사항이지 최적화가 아니다.**

- 진단한 실행은 최초 조회 시점에 로컬에 저장 (실행 상세 + 당시 봇 로직 + 진단 결과)
- 저장 경로는 `state_dir()` 아래, 계정 키로 분리
- 이미 스냅샷이 있으면 Control Room을 다시 안 부른다 (성능 이득도 있음)
- **원본이 만료됐는지 표시** — 90일 지난 실행은 "Control Room에서는 이미 사라졌고
  이건 로컬 스냅샷"이라고 응답에 명시

### Phase 8 — CLI + 서버 부팅 `cli.py`, `__main__.py`

- 인자/env 3단 우선순위 해석 (인스턴스 엔트리 → CLI 인자 → 환경변수)
- `${ENV}` 간접 참조 지원. **전체 값만 확장**하고 부분 참조는 거부
- 미치환 플레이스홀더(`REPLACE_WITH_...`, `changeme`) fail-fast
- 로깅: 옵트인 파일 + **PID 태깅**(여러 프로세스가 한 파일을 rotate하면 서로 갈아치움) + 7일 age sweep
- **부모 프로세스 워치독** — ppid 5초 폴링 후 self-exit. 없으면 고스트 서버가 쌓인다
- BrokenPipe는 정상 종료로 취급
- 종료 시 `AuthManager.logout()` 호출 → 세션 슬롯 반납 (atexit + 워치독 양쪽)
- 트랜스포트: stdio 기본, streamable-http 옵션

### Phase 9 — 브라우저 레이어 (마지막, 선택)

우선순위 낮음. API로 못 얻는 것만 담당한다:
콘솔 에러, XHR 트레이스, 스크린샷, computed CSS.

**절대 규칙**:
- **클릭하지 않는다.** 사람이 클릭하고 LLM은 결과만 읽는다
- 읽기 툴(`inspect_*`)은 **구조적으로 창을 못 띄운다**. 창을 띄우는 건 `open_debug_window` 하나뿐
- 창은 **자기 세션으로 로그인**한다 (API 세션과 분리). 따라서 **세션 슬롯을 하나 더 먹는다**
  → `SessionBudget`에 반영하고, 상한 근접 시 창 열기를 거부하라
- 미저장 입력 가드 — 편집 중인 폼이 있으면 이동 거부
- 런치 예산 — 15초 만에 사라진 창은 사용자가 닫은 게 아니라 크래시다. 재실행 거부

---

## 5. 코딩 규약

`CLAUDE.md`에 전문이 있다. 자주 어기는 것만 다시:

| 규칙 | 이유 |
|---|---|
| 툴 description ≤120자, 파라미터 ≤80자 | 매 요청마다 전송된다 |
| **툴을 토큰 근거로 통합하지 마라** | 실절감 139토큰(2.7%) vs 오라우팅 1회 500~9,000토큰 |
| **파라미터 description을 자르지 마라** | 라우팅 힌트가 지워져 오라우팅 |
| **메타라우터 금지** | 단일 진입점은 스키마 가이드를 파괴한다 |
| 액션 allowlist는 `_FIELDS_BY_ACTION`과 **쌍일 때만** 절감 | 숨기기만 하면 프로퍼티가 안 지워진다 |
| 불변 객체 — 수정 대신 새 객체 반환 | `Token`, `SessionSlot`이 `frozen=True`인 이유 |
| 함수 <50줄, 파일 <800줄, 중첩 <4단계 | early return 선호 |
| 매직넘버 금지 | 모듈 상단에 명명 상수 |
| 에러를 조용히 삼키지 마라 | 특히 인증 에러를 "0건"으로 만들지 마라 |
| 커버리지 80% 게이트 | `fail_under = 80` |
| **실제 Control Room 호스트/봇 이름/계정을 커밋하지 마라** | 공개 리포다 |

### 테스트 규약

- 모킹은 **2계층**: HTTP 레이어는 `AuthManager.make_request`를, 툴 레이어는
  `a360_api`의 조회 헬퍼 자체를 patch
- **`MagicMock(spec=...)` 항상 사용** — spec 없는 mock은 오타를 삼킨다
- **호출 인자를 통째로 assert** — 필드 프로젝션이 조용히 바뀌면 테스트가 깨져야 한다
- autouse 격리: 상태 디렉터리(이미 있음), 쿼리 캐시, 스냅샷 저장소, 세션 예산
- 시계를 섞지 마라 — `now=`를 주는 테스트는 **모든 호출에** 일관되게 줘라
  (안 그러면 stale 판정이 엉킨다. 실제로 한 번 당했다)

---

## 6. 참고 구현

`~/app/jupyterLab/notebooks/mfa-servicenow-mcp` — 같은 저자의 ServiceNow MCP 서버.
src 109파일 55k줄 / tests 183파일 68k줄의 프로덕션 코드다.

이 프로젝트의 구조·패턴은 전부 거기서 가져왔다. 막히면 대응되는 파일을 봐라:

| a360-mcp | mfa-servicenow-mcp |
|---|---|
| `tools/a360_api.py` | `tools/sn_api.py` |
| `utils/response_budget.py` | `utils/response_budget.py` |
| `utils/registry.py` | `utils/registry.py` |
| `policies/write_guards.py` | `policies/write_guards.py` |
| `server.py` | `server.py` |
| `browser/` | `browser/` |

**단, ServiceNow 고유라 가져오면 안 되는 것**: Table API `sysparm_*` 쿼리 문법,
`sys_id` 32자 hex 규약(A360은 정수 `id`/`fileId`/`botId`), `sys_updated_by`/`sys_mod_count`,
Update Set 개념 전체, Service Portal 동기화 레이어, curl_cffi TLS impersonation
(A360엔 불필요), `X-UserToken`/`g_ck` 쿠키 회전.

---

## 7. 다음에 할 일 (바로 시작 지점)

**Phase 2의 첫 파일**: `tests/test_a360_api.py`를 먼저 쓰고 `src/a360_mcp/tools/a360_api.py`를
구현하라. 시작은 가장 단순한 것부터:

1. `list_page(auth, path, *, filter=None, sort=None, offset=0, length=100)` — 한 페이지 조회
2. `list_all(auth, path, *, max_items)` — 페이징 순회 (상한 필수, 무한 순회 금지)
3. `strip_empty_fields(record)` — 빈 필드 제거
4. `to_columnar(records)` — 3행 이상이면 컬럼형

`[미검증]` 항목인 A360 필터 JSON의 정확한 연산자 목록은, 라이브 Control Room에
`POST /v2/devices/list`를 빈 필터로 한 번 던져 응답 구조를 확인한 뒤 확정하라.
