Initial commit

This commit is contained in:
2026-08-05 15:54:25 +09:00
commit a4eb5a580f
168 changed files with 12057 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
# Tool Service-MCP Bundle 조회 계약 v0.2
- 상태: **Implemented** (MCP 서버 측 구현 완료, Tool Service 측 합의 대기)
- 기준일: 2026-07-30
- 대체 대상: push 등록 방식(v0.1). 채택하지 않은 이유는 §2
- 조회 endpoint: `GET {manifestUrl}` — Tool Service가 제공
- 실행 endpoint: `POST {baseEndpoint}/{toolName}` — 현재 구현
## 1. 계약 범위와 원칙
Tool Service는 여러 Tool을 함께 배포하는 하나의 프로젝트이며, 이 계약에서 **bundle**이라 부른다.
MCP Server는 자기 설정에 선언된 bundle의 매니페스트를 **주기적으로 조회**해 Tool 목록을 구성한다.
| 원칙 | 내용 |
|---|---|
| MCP가 가져온다 | Tool Service는 매니페스트를 제공만 한다. MCP에 등록 요청을 보내지 않는다 |
| 조회 대상은 설정이 정한다 | 어떤 bundle이 이 MCP에 속하는지는 배포 시점 YAML로 확정된다 |
| **라우팅 주소는 설정이 소유한다** | 호출 대상 주소는 MCP 설정에서만 온다. 매니페스트가 바꿀 수 없다 |
| 매니페스트는 전체 상태 | 응답은 그 bundle의 Tool 전체 목록이다. 증분 없음 |
| 조회 성공이 생존 신호 | 별도 heartbeat·TTL 장치가 없다 |
| bundle 단위 조회 격리 | 한 bundle의 조회 실패가 다른 bundle의 조회를 중단시키지 않는다 |
| aggregate는 전부 아니면 전무 | 단, 직전 성공본조차 없는 bundle이 하나라도 있으면 카탈로그 전체를 교체하지 않는다 |
세 번째 원칙이 이 계약의 보안 기반이다. 매니페스트는 **무엇을 노출하는가**만 말하고
**어디로 호출할 것인가**는 말하지 않는다. Tool Service가 임의의 주소를 MCP에 주입할 수 없다.
마지막 두 원칙은 층이 다르다. **조회**는 bundle마다 독립이고 실패해도 직전 성공본이 남으므로
평소에는 한 bundle의 장애가 다른 bundle을 건드리지 않는다. 그러나 **카탈로그 교체**는 전부 아니면
전무다. 한 번도 성공한 적 없는 bundle이 남아 있으면 그 상태로 목록을 확정하지 않는다.
일부만 담긴 목록은 "필요한 Tool이 조용히 사라진 상태"를 만들기 때문이다(§7, §11 W11).
> **운영 배포에서 bundle은 항상 하나다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다
> ([ADR-0007](../../decisions/ADR-0007-one-mcp-per-tool-service.md)). 따라서 여러 bundle을 전제로 한
> 규칙(§7의 4·6번, `maxToolsTotal`)은 운영에서 발동하지 않는다. 계약과 구현은 N개를 계속 지원하지만
> 배포 정의가 1개로 잠그며, 그 사실은 `HelmDeploymentContractTest`가 검사한다.
## 2. 왜 조회 방식인가, 왜 기동 시 1회가 아닌가
### push를 채택하지 않은 이유
Tool Service가 MCP로 등록을 보내는 방식은 **MCP Server가 재기동되면 카탈로그를 복구할 방법이 없다.**
Tool Service는 이미 등록을 마쳤으므로 다시 보내지 않고, MCP는 빈 상태로 서비스한다.
재기동 빈도는 오히려 MCP 쪽이 높다(배포·스케일·노드 이동).
조회 방식은 MCP가 스스로 물어보므로 이 문제가 성립하지 않는다.
또한 MCP에 쓰기 endpoint를 열지 않아도 된다.
### 기동 시 1회로 끝내지 않는 이유
조회 방식이라도 기동 시 1회만 하면 아래를 따라가지 못한다.
| 상황 | 기동 시 1회만 | 주기적 조회 |
|---|---|---|
| MCP 재기동 | ✅ 다시 조회하므로 복구 | ✅ |
| Tool이 Tool 목록·schema 변경 | ❌ MCP 재기동 전까지 모름 | ✅ 다음 주기 반영 |
| Tool Service 장애 | ❌ 계속 노출 | ✅ 직전 성공본 유지, 정상 응답에서 삭제 확인 시 제거 |
| MCP 기동 시점에 Tool이 배포 중이라 응답 실패 | ❌ **영구 누락** | ✅ 다음 주기 복구 |
마지막 항목이 가장 위험하다. 조회는 반드시 주기적이어야 한다.
## 3. MCP 설정 (YAML)
조회 대상과 라우팅 주소를 선언한다. 예시는
[mcp-bundle-config.yaml](examples/bundle-v0.2/mcp-bundle-config.yaml)에 있다.
```yaml
mcp:
identity: mcp-insurance-core
registry:
refreshIntervalSeconds: 30
discovery:
enabled: true
connectTimeoutMillis: 1000
readTimeoutMillis: 3000
maxToolsPerBundle: 100
maxToolsTotal: 200
maxManifestBytes: 1048576
maxToolTimeoutMillis: 30000
bundles:
# 운영 배포에서 이 목록은 항상 한 항목이다(ADR-0007). 스키마는 N개를 허용한다.
- id: insurance-processing
manifestUrl: http://tool-processing.ax-hub.svc.cluster.local:8080/tool-manifest
baseEndpoint: http://tool-processing.ax-hub.svc.cluster.local:8080/mcp
namePrefix: "processing."
# local 검증에서만 사용. 최초 원격 조회 실패 때만 읽으며 운영 Helm에는 넣지 않는다.
fallbackManifestFile: file:./config/local-process-tools-manifest-sample-v1.json
enabled: true
```
| 항목 | 설명 |
|---|---|
| `discovery.enabled` | 운영에서는 `true`이며 bundle 매니페스트를 원천으로 사용한다. `false`는 legacy local JSON fixture에만 사용한다 |
| `manifestUrl` | 매니페스트 조회 주소 |
| `baseEndpoint` | **Tool 실행 주소.** Pod IP가 아니라 Service URL을 사용한다 |
| `namePrefix` | 이 bundle이 사용할 수 있는 Tool 이름 접두사 |
| `fallbackManifestFile` | 선택. 최초 원격 조회 실패 때만 읽을 local manifest 파일. 운영 Helm에는 설정하지 않는다 |
| `enabled` | `false`면 조회하지 않는다. Actuator 상태에는 `status: "disabled"`로 나타난다 |
`manifestUrl``baseEndpoint`를 나눈 이유는 매니페스트 제공 경로와 실행 경로가 다를 수 있기 때문이다.
같아도 무방하다.
원격 매니페스트와 legacy local JSON fixture는 **배타적**이다. `ToolRegistryClient` 구현은
`discovery.enabled`로 선택된다. 다만 local profile에서 원격 조회를 켠 경우에는 bundle별
`fallbackManifestFile`을 둘 수 있다. 이는 **최초 원격 조회가 실패했을 때만** 읽는 같은 매니페스트 형식의
cold-start fallback이며, 원격 정상 목록이나 직전 성공본을 덮어쓰지 않는다.
| profile | `discovery.enabled` | 등록되는 원천 | 결과 |
|---|:---:|---|---|
| `local` | `false` | `LocalFileToolRegistryClient` | legacy JSON fixture만 사용 |
| `local` | `true` | `ToolBundleRegistryClient` | 원격 우선, 설정 시 local manifest fallback |
| `local` 아님(`ocp` 등) | `true` | `ToolBundleRegistryClient` | 정상. 운영 |
| `local` 아님 | `false` | 없음 | **기동 실패** |
원천이 하나도 없으면 `ToolRegistryService`가 주입받을 bean이 없어 기동 단계에서 멈춘다.
빈 Tool 목록으로 조용히 뜨는 것보다 낫지만, 오류 메시지가 Spring의 bean 해석 실패이므로
원인을 바로 알기 어렵다. 두 profile YAML이 이미 올바른 값을 고정하고 있으므로
(`application-local.yml``application-ocp.yml``true`; legacy local fixture만 쓸 때에만 `false`)
새 profile을 추가할 때만 주의하면 된다.
### 조회 주기와 jitter
주기는 기존 `mcp.registry.refreshIntervalSeconds`를 사용한다. replica가 동시에 기동할 때
조회 쏠림을 줄이기 위해 첫 **scheduled refresh**에만 `refreshJitterSeconds` 범위의 bounded jitter를 더한다.
ApplicationReady 직후 warm start와 원천 preload는 빈 목록 구간을 줄이기 위해 jitter 없이 즉시 실행한다.
### 기동 시 검증
아래를 위반하면 **기동에 실패한다.** 잘못된 설정이 운영 중 엉뚱한 라우팅으로 나타나는 것보다 낫다.
| 규칙 | 이유 |
|---|---|
| `discovery.enabled=true`이면 `bundles`가 비어 있을 수 없다 | 이 상태로 뜨면 `tools/list`가 영구히 빈다 |
| `id`는 중복될 수 없다 | 상태 추적 단위가 겹친다 |
| `namePrefix`는 중복될 수 없고 다른 prefix의 접두사도 될 수 없다 | `a.``a.b.`가 함께 있으면 `a.b.search`의 소속이 확정되지 않는다 |
## 4. Tool Service가 제공할 endpoint
```text
GET {manifestUrl}
Accept: application/json
If-None-Match: "<직전 revision>" # 선택
```
매니페스트 조회는 사용자 요청이 아니라 **배경 갱신**이다. 특정 호출자의 요청 context가 없으므로
correlation·사원 식별자 header를 붙이지 않는다.
응답:
```text
200 OK
Content-Type: application/json
ETag: "sha256:9f2c..." # 선택. revision과 같은 값
```
응답 예시는 [manifest-response.json](examples/bundle-v0.2/manifest-response.json)을 따른다.
`If-None-Match`가 현재 `revision`과 같으면 `304 Not Modified`를 본문 없이 반환해도 된다.
MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기능이며 구현하지 않아도 계약을 만족한다.**
이 endpoint는 인증을 요구하지 않아도 되지만, **NetworkPolicy로 MCP Server에서만 접근 가능하도록
제한한다.** Tool 이름·설명·schema는 내부 시스템 구조를 드러내므로 클러스터 전체에 공개하지 않는다.
## 5. 매니페스트 스키마
### 최상위 필드
| 필드 | 필수 | 설명 |
|---|:---:|---|
| `bundleId` | 예 | MCP 설정의 `id`와 일치해야 한다. 다르면 그 응답을 버린다 |
| `revision` | 아니오 | 매니페스트 버전. 변경 감지·로그·ETag에만 쓰인다 |
| `tools` | 예 | 이 bundle이 노출하는 Tool 전체. 빈 배열은 "노출할 Tool 없음"이다 |
`baseEndpoint`**매니페스트에 넣지 않는다.** 넣어도 MCP는 무시한다(§1 세 번째 원칙).
### `tools[]` 필드
| 필드 | 필수 | 설명 |
|---|:---:|---|
| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 |
| `title` | 아니오 | 표시용 이름 |
| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 |
| `inputSchema` | 예 | JSON Schema 2020-12 |
| `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 |
| `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` |
| `_meta.version` | 예 | Tool 버전 |
| `_meta.timeoutMillis` | 아니오 | MCP 설정의 `maxToolTimeoutMillis`로 상한을 건다 |
| `_meta.enabled` | 아니오 | 기본 `true`. `false``tools/list`에 노출하지 않는다 |
`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`는 MCP가 `tools/list`
그대로 공개한다. `_meta`는 공개하지 않는다.
현재 MCP의 `tools/call``content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다.
MCP 2025-06-18에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는
구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다.
## 6. MCP의 조회 동작
| 항목 | 권장값 | 근거 |
|---|---|---|
| 주기 | `30초` | 변경 반영 지연의 상한 |
| 첫 scheduled refresh jitter | `0~5초` | 반복 조회 주기가 replica마다 같은 시점에 고정되는 것을 방지 |
| 연결 timeout | `1초` | |
| 읽기 timeout | `3초` | |
| 기동 시 | **즉시 1회 조회하되 기동을 막지 않는다** | Tool 장애가 MCP 기동 실패로 번지지 않게 |
| readiness | **첫 조회 시도 완료 + usable snapshot이면 ready** | 원천 또는 Redis last-good이 있어 실제 요청을 처리할 수 있을 때만 트래픽을 받는다 |
usable snapshot은 원천 조회 성공본, 최초 원격 조회 실패 때 채택한 local fallback, 또는 Redis에서 채택한 last-good이다. 정상 매니페스트가 반환한 빈 Tool
목록도 유효한 전체 상태다. 반대로 첫 조회가 끝났더라도 memory와 Redis에 성공본이 하나도 없으면 readiness는
DOWN을 유지하고 다음 주기 조회를 기다린다.
### 동시 조회
bundle N개를 **동시에** 조회한다. 순차 조회하면 소요 시간이 합산되어 기동과 갱신이 지연된다.
개별 조회 실패는 **예외가 아니라 결과값**으로 다룬다. 하나의 실패가 전체 조회를 중단시키면
나머지 성공분까지 버려진다.
### 실패 판정과 유예
| 조회 결과 | 처리 |
|---|---|
| 성공 + 검증 통과 | 새 매니페스트 채택 |
| 성공 + 검증 실패 | 직전 성공본 유지. 실패 횟수 증가 |
| timeout / 연결 실패 / 5xx | 직전 성공본 유지. 실패 횟수 증가 |
| `304 Not Modified` | 직전 성공본 유지. 실패 횟수 **초기화** |
`304`**아직 구현하지 않았다.** §4에서 선택 기능으로 둔 항목이므로 `If-None-Match`를 보내지 않고,
Tool Service가 `304`를 반환할 일도 없다. 매번 전체 매니페스트를 받아 채택한다.
구현상 실패는 **예외가 아니라 결과값**이다. 조회 작업이 예외를 그대로 올리면 `Future` 하나가 깨지면서
나머지 bundle의 성공분까지 함께 버려지기 때문이다. bundle별 작업이 자기 예외를 잡아 실패 결과로 바꾸고,
그 바깥에 예상 밖의 오류까지 흡수하는 2차 방어선을 둔다.
## 7. 병합 규칙
1. **이름 검증**`namePrefix`로 시작하지 않는 Tool은 그 bundle 전체를 거부한다
2. **`enabled: false` 제외** — 등록은 하되 `tools/list`에 노출하지 않는다
3. **상한 검사**`maxToolsPerBundle` 초과 시 그 bundle 거부, `maxToolsTotal` 초과 시 전체 aggregate 거부
4. **정렬**`(bundleId, name)` 오름차순으로 정렬한다
5. **`timeoutMillis` 상한** — `maxToolTimeoutMillis`를 넘는 값은 상한으로 절삭한다
6. **bundle 간 이름 충돌** — 어느 Tool도 임의 선택하지 않고 전체 aggregate를 거부한다
1번은 Tool 하나가 규칙을 어겨도 **bundle 전체를 거부**한다는 뜻이다. 필수 필드 누락, `bundleId` 불일치,
매니페스트 내부 이름 중복도 같다. 일부만 반영된 카탈로그는 "필요한 Tool이 조용히 사라진 상태"를 만들어,
직전 성공본을 유지하는 것보다 나쁘다.
6번을 정렬 **뒤에** 두는 이유는 1번과 같다. 정렬 전에 처리하면 어느 쪽이 살아남는지가
동시 조회의 응답 순서에 좌우되어 replica마다 달라진다.
정렬이 없으면 동시 조회 응답 순서에 따라 `tools/list` 순서가 매번 달라진다.
Agent Builder 쪽 프롬프트가 매 호출 달라져 캐시 적중률이 떨어지므로 반드시 정렬한다.
`maxToolsTotal`은 [ADR-0002](../../decisions/ADR-0002-tool-exposure-and-single-call.md)의
Tool 노출 상한과 함께 검토한다. 운영 배포는 bundle이 하나이므로(ADR-0007) 이 상한은
`maxToolsPerBundle`과 같은 층에서 동작하며, 50개 노출 상한은 한 Agent가 **여러 MCP에서 가져온
Tool의 합계**에 적용된다. MCP를 나눈다고 상한이 늘지 않는다.
## 8. Tool을 찾지 못했을 때의 재확인
`tools/call` 요청의 Tool이 현재 스냅샷에 없으면, **해당 bundle을 즉시 1회 재조회한 뒤**
그래도 없으면 `-32001 Tool not found`로 응답한다.
MCP replica마다 조회 시점이 달라 스냅샷이 일시적으로 어긋날 수 있기 때문이다(§11 W2).
구현은 해당 bundle 하나가 아니라 **전체를 한 번 재조회**한다. 재조회 대상은 병렬이고 timeout이 짧아
비용 차이가 작은 반면, "어느 bundle에 속한 Tool인가"를 이름만으로 되짚는 경로를 따로 두지 않아도 된다.
계약이 요구하는 것(한 번 더 확인한 뒤 판정)은 그대로 만족한다.
## 9. 운영 상태 조회
```text
GET /actuator/toolBundles
```
management port(운영 기본 9090)에서 MCP가 알고 있는 bundle의 조회 상태를 반환한다. 외부 ingress에는
노출하지 않는다. 응답 예시는
[bundle-status-response.json](examples/bundle-v0.2/bundle-status-response.json)에 있다.
설정에 선언되어 있으나 한 번도 조회에 성공하지 못한 bundle도 반환한다.
**설정에 기대값이 있으므로 누락 감지가 가능하다.**
| `status` | 의미 |
|---|---|
| `healthy` | 마지막 조회 성공 |
| `fallback` | 최초 원격 조회에 실패해 local manifest sample을 사용 중 |
| `degraded` | 최근 조회는 실패했지만 직전 성공본을 계속 노출 중 |
| `unreachable` | 켜져 있으나 한 번도 성공한 적 없음 |
| `disabled` | 설정에서 `enabled: false` |
응답에 **`manifestUrl``namePrefix`는 넣지 않는다.** 진단에 꼭 필요하지 않은데 내부 주소 체계를 더 드러낸다.
`lastFailureReason`도 메시지가 아니라 **예외 타입 이름만** 담는다. 메시지에는 URL이나 응답 조각이 섞일 수 있다.
읽기 전용이며 상태를 바꾸지 않는다. 그러나 내부 구조를 노출하므로 외부에 공개하지 않는다.
이 endpoint는 Spring Boot Actuator가 제공하므로 `/mcp`의 JSON-RPC 예외 처리 경계를 통과하지 않는다.
## 10. 실행 경로 (MCP → Tool, 현재 구현)
이미 구현되어 있는 계약이다. Tool Service는 아래를 받을 수 있어야 한다.
```text
POST {baseEndpoint}/{toolName}
Content-Type: application/json
guid, x-request-id, mcp-session-id, employee-no, virtual-employee-no
Authorization: <설정에 따라 전달>
<tools/call의 arguments 객체 원본>
```
- 호출자 header 다섯 개는 **이름과 값을 바꾸지 않고 그대로 bypass**한다. 값이 없는 header는 보내지 않는다.
- `employee-no`·`virtual-employee-no`는 호출자가 암호화한 값이다. **복호화는 Tool Service 몫이며
사내 KMS에서 발급받은 키를 사용한다.** MCP는 키를 갖지 않으므로 값을 읽지도, 로그에 남기지도 못한다.
MCP가 인증을 하지 않으므로([ADR-0006](../../decisions/ADR-0006-no-authentication-in-mcp.md))
이 값의 신뢰 여부는 Tool Service가 판단한다. 두 header가 모두 없을 수 있다는 점도 함께 고려한다.
- 발송·등록·변경 Tool의 중복 실행 방지는 Tool Service 책임이다. retry가 같은 `guid`를 재사용할지와
이를 멱등성 키로 사용할지는 아직 합의되지 않았으므로 현재 wire 계약으로 가정하지 않는다.
합의 대상은 [extension-points.md](../../extension-points.md)에 한 번만 관리한다.
- 요청 body는 에이전트가 보낸 `arguments` 객체 **그대로**다. MCP는 이름·값을 바꾸지 않는다.
- 응답이 JSON object/array면 MCP가 compact JSON 문자열로 `result.content[0].text`에 담는다.
- Tool이 반환한 HTTP 4xx/5xx와 timeout은 `result.isError: true`로 변환한다.
- 호출 소요 시간은 `result.content[0]._meta.searchTime`(ms)로 반환한다.
향후 Tool Service를 MCP 서버로 만들면 매니페스트 조회를 표준 `tools/list`로, 실행을 표준
`tools/call`로 대체할 수 있다. 이 경우 §4·§5는 MCP 표준으로 흡수된다. 전환 여부는 합의 항목이다.
## 11. 트레이드오프와 확장 경계
현재 선택은 **Tool Service 원천 + 주기 pull + in-memory last-good + 선택 Redis 공유 cache**다.
Redis의 key 형식, TTL, 공유 범위와 운영 정책은 이 Tool Service wire 계약의 범위가 아니며
[extension-points.md](../../extension-points.md#운영-적용-전-필수-보완)에서 합의한다.
| 트레이드오프 | 현재 선택 |
|---|---|
| replica snapshot 차이 | 일시 허용. 성공본만 교체하고 Tool miss 시 원천을 한 번 재확인 |
| 변경 반영 지연 | 기본 한 주기 허용. 즉시 알림·ETag는 아직 도입하지 않음 |
| 조회 부하 | replica별 조회 허용. 규모가 커질 때만 leader election 검토 |
| 기동 중 원천 장애 | Redis warm start 후 즉시 preload. local profile에 fallback 파일이 있으면 최초 실패 때만 채택하고, 없으면 다음 조회까지 Registry unavailable |
| stale Tool | 조회 실패만으로 삭제하지 않음. 성공한 매니페스트에서 빠진 경우에만 삭제 |
| Redis 장애 | cache miss로 격리. 요청 경로는 in-memory만 조회 |
NetworkPolicy·egress, 관측 지표, retry/idempotency, outputSchema 등 아직 합의하거나 보완할 내용은
[extension-points.md](../../extension-points.md)에서만 관리한다. 구현 목록은 코드와 테스트가 정본이며 이 계약에 다시 나열하지 않는다.
## 12. 의도적으로 넣지 않은 기능
- `If-None-Match` / `304`: 매니페스트가 작아 현재 효용이 없음
- 즉시 refresh 알림 endpoint: 주기 반영으로 부족하다는 운영 근거가 생길 때 검토
- leader election: replica 조회 부하가 실제 병목이 될 때 검토
- 상태 응답의 `manifestUrl`·`namePrefix`: 불필요한 내부 주소 노출 방지