Files
dap-was-dapms/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md
koseokmin cb29b192b4 docs를 저장소로 되돌리고 계약 예제를 복원한다
5cfb8a1이 .gitignore에 docs/를 넣고 68개 파일을 지웠다. 그런데
AgentBuilderContractExampleTest, ToolBundleContractExampleTest,
ArchitectureDocumentContractTest는 docs/ 아래 계약 예제와 architecture
문서를 입력으로 직접 읽는다. 그 결과 clean clone에서 테스트 10건이
입력을 찾지 못해 실패했다.

제외 범위를 원래 의도대로 좁힌다. 에이전트 산출물(AGENTS.md, .agents/,
.codex/, docs/superpowers/)은 계속 제외하고 저장소 문서는 추적한다.

문서는 삭제 직전 상태(3de052a)를 기준으로 복원하고, 그 위에 main 코드와
대조해 어긋난 부분을 고쳤다.

- ADR-0007을 Superseded로 바꾸고 ADR-0013을 새로 쓴다. route당 Tool
  Service N개가 최종안이며, PortalToolRegistryClient가 이미 route별로
  N개를 유지하고 있는데 ADR-0007은 "bundles는 항상 한 항목"을 Accepted
  상태로 주장하고 있었다. ADR-0009 결정 4도 부분 대체한다.
- ADR-0008 파일 헤더가 Accepted였으나 ADR-0009가 이미 대체한 상태였다.
- 6078852의 endpoint 소유권 반전이 반영되지 않은 서술을 계약 v0.2,
  bundle 설정 예제, Tool 적재 안내에서 고친다.
- Portal registry 계약 v0.1과 route key 규약을 새로 문서화한다. 둘 다
  구현은 있는데 계약 문서가 없었다.
- MCP SDK 2.0.0 SBOM을 추가한다.

번호 주석: ADR-0011과 0012는 feature/mcp-integration이 Tool inputSchema
정책에 쓰고 있어 비워 둔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:27:56 +09:00

356 lines
22 KiB
Markdown

# Tool Service-MCP Bundle 조회 계약 v0.2
- 상태: **Implemented** (MCP 서버 측 구현 완료, Tool Service 측 합의 대기)
- 기준일: 2026-07-30
- 대체 대상: push 등록 방식(v0.1). 채택하지 않은 이유는 §2
- 조회 endpoint: `GET {manifestUrl}` — Tool Service가 제공
- 실행 endpoint: Tool Service manifest의 top-level `endpoint` 또는 `_meta.endpoint` — 현재 구현
## 1. 계약 범위와 원칙
Tool Service는 여러 Tool을 함께 배포하는 하나의 프로젝트이며, 이 계약에서 **bundle**이라 부른다.
MCP Server는 자기 설정에 선언된 bundle의 매니페스트를 **주기적으로 조회**해 Tool 목록을 구성한다.
| 원칙 | 내용 |
|---|---|
| MCP가 가져온다 | Tool Service는 매니페스트를 제공만 한다. MCP에 등록 요청을 보내지 않는다 |
| 조회 대상은 설정이 정한다 | 어떤 bundle이 이 MCP에 속하는지는 배포 시점 YAML로 확정된다 |
| **Tool Server domain은 포털이, Tool endpoint는 Tool Service가 소유한다** | 포털은 Tool Server의 `serviceDomain``manifestPath`만 제공하고, 개별 Tool 실행 endpoint는 Tool Service manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 온다 |
| 매니페스트는 전체 상태 | 응답은 그 bundle의 Tool 전체 목록이다. 증분 없음 |
| 조회 성공이 생존 신호 | 별도 heartbeat·TTL 장치가 없다 |
| bundle 단위 조회 격리 | 한 bundle의 조회 실패가 다른 bundle의 조회를 중단시키지 않는다 |
| aggregate는 전부 아니면 전무 | 단, 직전 성공본조차 없는 bundle이 하나라도 있으면 카탈로그 전체를 교체하지 않는다 |
세 번째 원칙이 이 계약의 신뢰 경계다. 매니페스트는 **무엇을 노출하는가**와 **어디로 호출할 것인가**를
함께 말한다. MCP는 endpoint의 scheme(HTTP(S))과 host 존재만 형식 검사하고 도메인 허용목록을 두지
않으므로, 매니페스트 원천의 신뢰성이 그대로 outbound 대상의 신뢰성이 된다. 따라서 §4의 "매니페스트
endpoint를 MCP Server namespace에서만 접근 가능하게 한다"는 요구는 선택적 강화가 아니라 이 계약의
전제 조건이다([ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
마지막 두 원칙은 층이 다르다. **조회**는 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` | 매니페스트의 **상대** endpoint를 절대 URL로 바꿀 때 쓰는 기준 주소. Pod IP가 아니라 Service URL을 사용한다. Tool 실행 주소 자체는 매니페스트가 정한다 |
| `namePrefix` | 이 bundle이 사용할 수 있는 Tool 이름 접두사 |
| `fallbackManifestFile` | 선택. 최초 원격 조회 실패 때만 읽을 local manifest 파일. 운영 Helm에는 설정하지 않는다 |
| `enabled` | `false`면 조회하지 않는다. Actuator 상태에는 `status: "disabled"`로 나타난다 |
`manifestUrl``baseEndpoint`를 나눈 이유는 매니페스트 제공 경로와 실행 경로가 다를 수 있기 때문이다.
같아도 무방하다. 매니페스트가 절대 URL을 선언하면 `baseEndpoint`는 해석에 쓰이지 않지만, 그때도
`baseEndpoint`는 절대 HTTP(S)여야 한다. MCP가 endpoint 종류와 무관하게 먼저 검사하므로 값이 잘못되면
그 bundle 전체가 거부된다.
원격 매니페스트와 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 없음"이다 |
Tool 실행 endpoint는 각 Tool의 top-level `endpoint` 또는 `_meta.endpoint`에 넣는다. 상대 경로를 쓰면 포털 registry의 `serviceDomain` 뒤에 붙고, 절대 URL을 쓰면 Tool Service manifest가 제공한 실행 주소 원천으로 그대로 사용한다. HTTP(S)가 아닌 scheme은 거부한다.
### `tools[]` 필드
| 필드 | 필수 | 설명 |
|---|:---:|---|
| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 |
| `title` | 아니오 | 표시용 이름 |
| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 |
| `inputSchema` | 예 | JSON Schema 2020-12 |
| `endpoint` 또는 `_meta.endpoint` | 예 | Tool 실행 주소. top-level `endpoint`를 먼저 읽고 없으면 `_meta.endpoint`를 쓴다. 둘 다 없으면 Bundle 전체를 거부한다 |
| `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``endpoint`는 내부 실행 정보이므로 공개본에서 제거한다.
현재 MCP의 `tools/call``content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다.
MCP 2025-11-25에서 `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 {endpoint}
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`: 불필요한 내부 주소 노출 방지