Compare commits
2 Commits
58d3014a0f
...
4e4c341f5d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e4c341f5d | |||
| ad7fccbed1 |
36
docs/contracts/portal-mcp/README.md
Normal file
36
docs/contracts/portal-mcp/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Portal-MCP 계약 문서
|
||||
|
||||
이 디렉터리는 포털과 MCP Server 사이의 Tool Server registry 조회 계약을 관리한다.
|
||||
|
||||
```text
|
||||
Portal ──[portal-mcp 계약]──▶ MCP Server ──[tool-service-mcp 계약]──▶ Tool Server
|
||||
▲
|
||||
└──[agent-builder-mcp 계약]── Agent Builder
|
||||
```
|
||||
|
||||
| 문서 | 상태 | 용도 |
|
||||
|---|---|---|
|
||||
| [protocol-v0.1-registry.md](protocol-v0.1-registry.md) | Implemented | route별 Tool Server 목록 조회 계약. 응답 형태, 필드, 실패 동작, 갱신 주기 |
|
||||
|
||||
## 현재 원칙
|
||||
|
||||
- 포털은 **route가 무엇이고 그 route에 어떤 Tool Server가 있는가**만 소유한다. `serviceDomain`과 `manifestPath`까지다.
|
||||
- Tool 목록과 Tool 실행 endpoint는 포털이 아니라 Tool Server 매니페스트에서 온다([ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||
- registry 응답은 그 시점의 전체 상태다. 증분은 없다.
|
||||
- 조회 실패는 route 삭제가 아니다. 성공한 registry가 route를 제외했을 때만 제거를 반영한다.
|
||||
- route 조회는 서로 독립이지만, 한 route 안에서는 전부 아니면 전무다. 부분 목록으로 snapshot을 만들지 않는다.
|
||||
- MCP는 요청 경로에서 포털을 호출하지 않는다. route key 검증도 in-memory snapshot만 본다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 요청 URL의 route key 규약: [Agent Builder 계약 v0.3](../agent-builder-mcp/protocol-v0.3-streaming-policy.md#공개-url과-route-key)
|
||||
- 매니페스트 조회·실행 계약: [Tool Service 계약 v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md)
|
||||
|
||||
## 운영 적용 전 확정할 항목
|
||||
|
||||
1. 포털 API의 인증 방식과 MCP → 포털 방향 NetworkPolicy
|
||||
2. `registryRevision`의 형식과 변경 통지 방식
|
||||
3. route 추가·폐기 시 rolling 호환 기간
|
||||
4. 저장소 샘플(`config/local-toolserver-info-sample-v1.json`, `deploy/portal-registry.json`)을 이 계약에 맞추는 시점
|
||||
|
||||
상세 필드와 장애 처리는 [v0.1 계약](protocol-v0.1-registry.md)을 따른다.
|
||||
117
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal file
117
docs/contracts/portal-mcp/protocol-v0.1-registry.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Portal-MCP Tool Server Registry 계약 v0.1
|
||||
|
||||
- 상태: **Implemented** (MCP 서버 측 구현 완료, 포털 측 합의 대기)
|
||||
- 기준일: 2026-08-22
|
||||
- 조회 위치: `mcp.portal.registry-url` — HTTP(S) Portal API 또는 `file:`/`classpath:` 로컬 리소스
|
||||
- 활성 조건: `mcp.portal.enabled=true`
|
||||
- 구현: `PortalToolRegistryClient`
|
||||
|
||||
## 1. 계약 범위와 원칙
|
||||
|
||||
포털은 **route별로 어떤 Tool Server가 있는가**만 알려 준다. Tool 목록과 Tool 실행 주소는 포털이 아니라 각 Tool Server의 매니페스트에서 온다([Tool Service-MCP Bundle 조회 계약 v0.2](../tool-service-mcp/protocol-v0.2-bundle-discovery.md), [ADR-0010](../../decisions/ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||
|
||||
| 원칙 | 내용 |
|
||||
|---|---|
|
||||
| MCP가 가져온다 | 포털은 registry를 제공만 한다. MCP에 push하지 않는다 |
|
||||
| 포털은 route와 Tool Server만 소유 | `serviceDomain`과 `manifestPath`까지다. Tool 목록·실행 endpoint는 매니페스트가 정한다 |
|
||||
| registry는 전체 상태 | 응답은 그 시점 route 전체다. 증분 없음 |
|
||||
| route 단위 격리 | 한 route의 manifest 조회 실패가 다른 route의 snapshot을 지우지 않는다 |
|
||||
| 요청 경로는 조회하지 않는다 | `tools/list`·`tools/call`과 route key 검증은 in-memory snapshot만 본다 |
|
||||
|
||||
## 2. 응답 형태
|
||||
|
||||
두 가지를 모두 받는다. `routes`가 배열이면 집계형으로, 아니면 단일 route로 해석한다.
|
||||
|
||||
**집계형** — 한 번의 호출로 모든 route를 받는다. 운영에서 사용한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"registryRevision": "portal-registry-2026-08-22-01",
|
||||
"routes": [
|
||||
{
|
||||
"routeKey": "cus",
|
||||
"toolServices": [
|
||||
{
|
||||
"serviceKey": "was-cus",
|
||||
"serviceDomain": "https://tool-cus.devjun.net",
|
||||
"manifestPath": "/tool-manifest",
|
||||
"namePrefix": "",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**단일 route형** — `routes` 없이 최상위에 `routeKey`와 `toolServices`를 둔다.
|
||||
|
||||
```json
|
||||
{
|
||||
"routeKey": "cus",
|
||||
"toolServices": [ { "serviceKey": "was-cus", "serviceDomain": "https://tool-cus.devjun.net", "manifestPath": "/tool-manifest", "status": "ACTIVE" } ]
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 필드
|
||||
|
||||
| 필드 | 위치 | 필수 | MCP 처리 |
|
||||
|---|---|---|---|
|
||||
| `routes[]` | 최상위 | 선택 | 배열이면 집계형. 없으면 최상위를 단일 route로 읽는다 |
|
||||
| `routeKey` | route | **필수** | 정규화 후 route 식별자. 요청 URL `/mcp/{routeKey}`와 대조한다 |
|
||||
| `toolServices[]` | route | **필수** | 이 route가 보는 Tool Server 목록 |
|
||||
| `serviceKey` | service | **필수** | bundle id로 사용. 매니페스트의 `bundleId`와 일치해야 한다 |
|
||||
| `serviceDomain` | service | **필수** | Tool Server 주소. 후행 `/`는 제거한다. 매니페스트의 **상대** endpoint를 절대 URL로 바꾸는 기준이 된다 |
|
||||
| `manifestPath` | service | **필수** | 매니페스트 경로. `serviceDomain + manifestPath`가 조회 주소다 |
|
||||
| `status` | service | 선택 | 기본 `ACTIVE`. 대소문자 무시하고 `ACTIVE`가 아니면 그 서비스를 건너뛴다 |
|
||||
| `namePrefix` | service | 선택 | 기본 `""`. Tool 이름 접두사 검증에 사용한다 |
|
||||
| `registryRevision` | 최상위 | 선택 | 변경 진단·로그용. 호출 대상 결정에는 쓰지 않는다 |
|
||||
|
||||
**MCP가 읽지 않는 필드가 있다.** 현재 구현은 `executeBasePath`, `displayName`, `toolEndpoints`를 무시한다. 응답에 있어도 오류가 아니지만 동작에 영향을 주지 않으므로, 포털이 이를 근거로 실행 주소를 통제할 수 있다고 가정하면 안 된다.
|
||||
|
||||
## 4. 실패 동작
|
||||
|
||||
| 상황 | MCP 동작 |
|
||||
|---|---|
|
||||
| registry 조회 실패 | 이미 확보한 in-memory endpoint 목록 유지. memory가 비어 있으면 `mcp.redis.portal-registry-key`의 Redis fallback을 읽는다 |
|
||||
| Redis fallback도 실패 | 원천 미확보로 처리하고 다음 주기에 재시도 |
|
||||
| route에 ACTIVE 서비스가 하나도 없음 | `Portal registry has no active Tool Service`로 그 route 조회 실패 |
|
||||
| 한 Tool Server의 매니페스트에 사용 가능한 성공본이 없음 | 그 route 전체를 실패 처리. 부분 목록을 채택하지 않는다 |
|
||||
| route 간 Tool name 중복 또는 `maxToolsTotal` 초과 | 같은 이유로 실패 처리 |
|
||||
| registry에서 사라진 route | 다음 갱신에 in-memory snapshot에서도 제거 |
|
||||
|
||||
route 단위 격리와 catalog 교체 규칙은 Tool Service 계약 v0.2 §7과 같은 원칙을 따른다. 조회는 route마다 독립이지만, 한 route 안에서는 전부 아니면 전무다.
|
||||
|
||||
## 5. 갱신 주기
|
||||
|
||||
- `mcp.portal.refresh-interval-seconds` (기본 300초): 포털 registry만 다시 읽는다.
|
||||
- `mcp.registry.refresh-interval-seconds`: 이미 확보한 Tool Server 목록의 매니페스트만 다시 읽는다.
|
||||
|
||||
기동 preload는 포털 registry를 먼저 호출한 뒤 매니페스트를 조회한다. 두 주기는 독립이다.
|
||||
|
||||
## 6. 로컬 검증
|
||||
|
||||
`registry-url`에 `file:` 또는 `classpath:` 리소스를 지정하면 포털 서버 없이 같은 계약으로 읽는다. 이후 매니페스트 조회·route별 snapshot 갱신·Redis fallback 규칙은 HTTP API를 쓸 때와 동일하다.
|
||||
|
||||
```yaml
|
||||
mcp:
|
||||
portal:
|
||||
enabled: true
|
||||
registry-url: file:./config/local-toolserver-info-sample-v1.json
|
||||
refresh-interval-seconds: 15
|
||||
```
|
||||
|
||||
## 7. 저장소의 샘플 파일
|
||||
|
||||
두 샘플이 있고, 현재 서로 다르다. 이 계약을 정본으로 삼고 맞춰야 한다.
|
||||
|
||||
| 파일 | 용도 | 이 계약과의 차이 |
|
||||
|---|---|---|
|
||||
| `config/local-toolserver-info-sample-v1.json` | 로컬 검증용 | MCP가 읽지 않는 `displayName`을 포함 |
|
||||
| `deploy/portal-registry.json` | 배포 참고용 | MCP가 읽지 않는 `executeBasePath`를 포함하고 `registryRevision`이 없다 |
|
||||
|
||||
## 8. 열린 항목
|
||||
|
||||
- 포털 API의 인증 방식은 이 계약이 정하지 않는다. MCP는 인증·인가를 하지 않으므로([ADR-0006](../../decisions/ADR-0006-no-authentication-in-mcp.md)) 네트워크 경계에서 통제한다.
|
||||
- `registryRevision`의 형식을 문자열로 고정할지 정하지 않았다. 현재 구현은 값을 로그·진단에만 쓰므로 형식에 의존하지 않는다.
|
||||
- 즉시 refresh 알림: 주기 반영으로 부족하다는 운영 근거가 생길 때 검토한다.
|
||||
@@ -1,9 +1,14 @@
|
||||
# ADR-0007 MCP 배포 하나는 Tool Service 하나만 본다
|
||||
|
||||
- 상태: Accepted
|
||||
- 상태: Superseded
|
||||
- 결정일: 2026-08-02
|
||||
- 대체 결정: [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)
|
||||
- 관련: [ADR-0001](ADR-0001-stateless-execution-boundary.md), [ADR-0002](ADR-0002-tool-exposure-and-single-call.md), [ADR-0009](ADR-0009-container-handles-public-mcp-path.md), [계약 v0.2](../contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md)
|
||||
|
||||
> 이 문서는 당시 검토 이력을 보존한다. 내부망 운영은 endpoint 목록과 route 매핑의 원천을 Portal로 옮겼으므로
|
||||
> 현재 구현과 신규 연동에는 [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)을 적용한다.
|
||||
> 아래 격리 논거는 폐기된 것이 아니라 ADR-0013이 무엇을 포기했는지 판단하는 근거로 남는다.
|
||||
|
||||
외부에서 여러 MCP를 하나의 host 아래 path로 묶는 방식은 [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)이
|
||||
소유한다. OpenShift Route가 원래 path를 유지한 채 각각의 독립 배포로 연결하므로 이 ADR의 1:1 결정은 그대로 유지된다.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-05
|
||||
- 대체: [ADR-0008](ADR-0008-shared-host-path-routing.md)
|
||||
- 부분 대체됨: 결정 4는 [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md)이 대체한다
|
||||
- 관련: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
|
||||
|
||||
## 배경
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR-0010 Tool 실행 endpoint를 Tool Service 매니페스트가 선언한다
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-19
|
||||
- 기록: 2026-08-22. 구현 커밋 `6078852`를 기준으로 사후 작성했다.
|
||||
- 관련: [ADR-0006](ADR-0006-no-authentication-in-mcp.md), [ADR-0007](ADR-0007-one-mcp-per-tool-service.md)
|
||||
|
||||
## 배경
|
||||
|
||||
이전 설계에서 Tool 실행 주소는 MCP 배포 설정이 단독으로 소유했다. `mcp.bundles[].baseEndpoint` 뒤에 Tool name을 붙여 `POST {baseEndpoint}/{toolName}`을 만들었고, 매니페스트가 endpoint 성격의 값을 담고 있어도 읽지 않았다. **Tool Service가 반환한 어떤 값도 MCP가 요청을 보내는 대상을 바꿀 수 없다**는 것이 이 설계의 핵심이었고, `docs/architecture.md`, Tool Service 계약 v0.2, `ToolBundleDiscovery`의 Javadoc, `application.yml` 주석이 같은 문장을 반복해 고정하고 있었다.
|
||||
|
||||
Portal registry를 Tool Server 목록의 원천으로 도입하면서 전제가 무너졌다. 포털은 route별로 Tool Server의 `serviceDomain`과 `manifestPath`만 제공한다. Tool 하나하나의 실행 경로는 포털이 모르고, MCP 배포 설정도 미리 알 수 없다. 기존 구조를 유지하려면 Tool을 추가하거나 경로를 바꿀 때마다 배포 설정의 endpoint 목록을 함께 고쳐야 했고, 이는 Tool Service의 배포 주기와 MCP의 배포 주기를 묶어 버린다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. Tool 실행 주소는 Tool Service 매니페스트가 선언한다. MCP는 각 Tool의 top-level `endpoint`를 먼저 읽고, 없으면 `_meta.endpoint`를 사용한다.
|
||||
2. 둘 다 없거나 비어 있으면 그 Bundle 전체를 거부한다. Tool 하나의 누락이 나머지 Tool을 조용히 통과시키지 않는다.
|
||||
3. 상대 경로는 Portal registry가 제공한 `serviceDomain` 뒤에 붙여 절대 URL로 만든다. 이것이 운영에서 기대하는 형태다.
|
||||
4. 절대 URL은 Tool Service가 제공한 실행 주소 원천으로 그대로 사용한다. scheme이 HTTP(S)가 아니거나 host가 없으면 거부한다. 프로토콜 상대 주소(`//host/path`)와 개행이 섞인 값도 거부한다.
|
||||
5. `mcp.bundles[].baseEndpoint`는 더 이상 실행 주소의 정본이 아니다. 상대 경로를 해석하는 기준으로만 남으며, 절대 HTTP(S)여야 한다.
|
||||
6. `endpoint`는 내부 실행 정보이므로 `_meta`와 함께 제거해 `tools/list` 공개본에 내보내지 않는다.
|
||||
|
||||
```text
|
||||
manifest endpoint = "/mcp/processing.contract.inquiry" (운영 관례)
|
||||
-> https://tool-cus.devjun.net/mcp/processing.contract.inquiry
|
||||
|
||||
manifest endpoint = "https://other.example/tool" (허용되지만 위험)
|
||||
-> https://other.example/tool
|
||||
```
|
||||
|
||||
## 영향
|
||||
|
||||
- Tool을 추가하거나 실행 경로를 바꿀 때 MCP 배포 설정을 함께 바꾸지 않아도 된다. Tool Service가 매니페스트만 갱신하면 다음 refresh에 반영된다.
|
||||
- **신뢰 경계가 이동한다.** 이전에는 배포 설정이 outbound 대상을 봉인했으나, 이제는 매니페스트가 결정한다. 매니페스트가 절대 URL을 선언하면 MCP는 그 호스트로 요청을 보낸다.
|
||||
- 검증은 두 지점에 있다. discovery 시점에 `ToolBundleDiscovery`가 scheme·host·프로토콜 상대 주소·개행을 확인하고, 실행 시점에 `ToolRoutingService.validateEndpoint()`가 절대 HTTP(S)인지 다시 확인한다. 둘 다 **형식 검사이며 도메인 허용목록은 없다.** 따라서 매니페스트 원천의 신뢰성이 곧 outbound 대상의 신뢰성이다.
|
||||
- 네트워크 계층의 완화도 없다. `deploy/helm/mcp-server/templates/networkpolicy.yaml`은 `policyTypes: [Ingress]`만 선언하므로 outbound 목적지를 제한하지 않는다. 이 저장소가 제공하는 allowlist(`route.sourceAllowlist`, NetworkPolicy)는 모두 inbound 통제다.
|
||||
- [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라 MCP는 인증·인가를 하지 않는다. 그래서 "`GET /tool-manifest`를 NetworkPolicy로 MCP Server namespace에서만 접근 가능하게 한다"는 기존 요구가 선택적 권고가 아니라 이 결정의 전제 조건이 된다.
|
||||
- endpoint 검증 실패는 Bundle 전체 거부로 처리되고 직전 정상 snapshot이 유지되므로, 잘못된 매니페스트 배포가 기존 Tool 목록을 지우지는 않는다.
|
||||
- 계약 문서와 예제가 함께 갱신됐다. `docs/contracts/tool-service-mcp/examples/bundle-v0.2/manifest-response.json`이 `endpoint`를 포함하며, `ToolBundleContractExampleTest`가 문서와 구현의 일치를 고정한다.
|
||||
|
||||
## 남은 위험
|
||||
|
||||
- **도메인 허용목록 부재.** 매니페스트가 임의의 HTTP(S) 호스트를 지정할 수 있고, 애플리케이션 검사도 네트워크 정책도 이를 좁히지 않는다. 내부망 운영 전에 두 방향 중 하나를 정해야 한다.
|
||||
- `mcp.tool-domains` 형태의 allowlist를 두고 절대 URL을 그에 대조한다.
|
||||
- 절대 URL을 아예 거부하고 상대 경로만 허용해 목적지를 Portal registry의 `serviceDomain`으로 봉인한다. 운영 예제가 이미 상대 경로만 쓰고 있어 비용이 가장 낮고, 이전 설계의 "Tool Service가 호출 대상을 바꿀 수 없다"는 성질도 회복된다.
|
||||
- egress NetworkPolicy를 함께 검토한다. 위 두 방안 중 무엇을 택하든 애플리케이션 단독 방어보다 낫다.
|
||||
- 이 ADR은 기존 ADR을 대체하지 않는다. 뒤집힌 불변식이 ADR이 아니라 architecture 문서와 코드 주석에만 있었기 때문이다. 같은 일이 반복되지 않도록 실행 주소 관련 결정은 앞으로 이 ADR을 갱신하거나 후속 ADR로 남긴다.
|
||||
@@ -0,0 +1,72 @@
|
||||
# ADR-0011 Tool inputSchema는 문서 밖을 참조하지 않는다
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-18
|
||||
- 관련 결정: [ADR-0006](ADR-0006-no-authentication-in-mcp.md) · [ADR-0004](ADR-0004-execution-guardrails.md)
|
||||
|
||||
## 배경
|
||||
|
||||
MCP Java SDK를 도입하면서 JSON Schema 2020-12 검증을 `com.networknt:json-schema-validator`에 위임했다
|
||||
([mcp-java-sdk-adoption.md](../mcp-java-sdk-adoption.md)). 그런데 JSON Schema의 `$ref`는 같은 문서 안뿐 아니라
|
||||
**다른 주소의 문서**를 가리킬 수 있고, 검증기는 그런 참조를 만나면 그 주소로 직접 조회를 시도한다.
|
||||
|
||||
`inputSchema`는 Tool Service 매니페스트에서 온다. 즉 매니페스트에 이런 schema가 실리면
|
||||
|
||||
```json
|
||||
{"type":"object","properties":{"q":{"$ref":"http://any-host/whatever.json"}}}
|
||||
```
|
||||
|
||||
MCP가 그 주소로 요청을 보낸다. 이것은 [AGENTS.md §2](../../AGENTS.md)의 불변식과 정면으로 어긋난다.
|
||||
|
||||
> outbound 주소는 설정에서만 온다. 요청 값도 매니페스트도 호출 대상을 바꾸지 못한다.
|
||||
|
||||
매니페스트가 선언한 `endpoint`를 무시하는 규칙은 이미 있고 테스트로 잠겨 있다. `$ref`는 같은 불변식을
|
||||
같은 방식으로 깨는데 통제가 없던 경로였다. SDK 도입이 열어 놓은 구멍이다.
|
||||
|
||||
[ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라 MCP는 인증·인가를 하지 않으므로, 이 경로 앞에서
|
||||
호출자를 걸러 주는 계층도 없다.
|
||||
|
||||
## SDK 설정으로는 막을 수 없다
|
||||
|
||||
`DefaultJsonSchemaValidator`는 `SchemaRegistry`를 생성자 안에서 직접 만들고 `private final`로 들고 있다.
|
||||
공개 생성자는 `()`와 `(ObjectMapper)` 둘뿐이라, 참조 해석 정책을 담은 설정을 밖에서 넣을 자리가 없다.
|
||||
검증기 쪽에서 끄는 선택지는 존재하지 않는다.
|
||||
|
||||
## 결정
|
||||
|
||||
**Tool의 `inputSchema`는 문서 밖을 가리키는 참조를 담을 수 없다.** 검증기에 넘기기 전에, schema가 Registry로
|
||||
들어오는 시점에 거부한다.
|
||||
|
||||
| 대상 | 규칙 |
|
||||
|---|---|
|
||||
| `$ref`, `$dynamicRef` | 값이 `#`으로 시작해야 한다. 즉 같은 문서 안의 위치만 가리킨다 |
|
||||
| `$schema` | 선언했다면 `https://json-schema.org/draft/2020-12/schema`여야 한다 |
|
||||
| `$id` | 제한하지 않는다 |
|
||||
|
||||
`$id`를 열어 두는 이유는, 문서 밖 참조가 모두 막히면 base URI가 무엇이든 조회가 일어나지 않기 때문이다.
|
||||
막을 이유가 없는 것까지 막으면 정상 Tool만 거부된다.
|
||||
|
||||
검사 지점은 `ToolMetadata`의 표준 생성자다. Portal 매니페스트 파싱, local 파일 로딩, Redis snapshot 역직렬화가
|
||||
모두 이 생성자를 지나므로 **경로마다 검사를 흩어 놓지 않아도 우회 경로가 생기지 않는다.**
|
||||
|
||||
위반은 기존 매니페스트 형식 오류와 같게 다룬다. 따라서 bundle 단위 실패 격리와 "Redis 실패는 언제나 cache miss"
|
||||
불변식이 그대로 적용되고, 한 Tool의 잘못된 schema가 다른 bundle의 정상 Tool을 지우지 않는다.
|
||||
|
||||
## 검토한 대안
|
||||
|
||||
| 대안 | 채택하지 않은 이유 |
|
||||
|---|---|
|
||||
| 검증기 설정으로 원격 해석 차단 | 위 절대로 주입 지점이 없다 |
|
||||
| `JsonSchemaValidator`를 직접 구현 | SDK에 표준 검증을 위임한다는 도입 전제를 되돌리게 된다. networknt API를 우리가 떠안고, SDK 업그레이드마다 정책이 조용히 어긋날 수 있다 |
|
||||
| egress 방화벽만으로 차단 | 심층 방어로는 유효하지만 단독으로는 부족하다. 플랫폼 설정에 의존하고, 차단되지 않은 내부 주소에는 여전히 도달한다 |
|
||||
|
||||
egress 통제는 이 결정을 대체하지 않고 함께 둔다.
|
||||
|
||||
## 영향
|
||||
|
||||
- Tool Service는 `inputSchema`를 자기 문서 안에서 완결시켜야 한다. 공통 타입은 `$defs`로 같은 문서에 넣고
|
||||
`#/$defs/...`로 참조한다. 이 항목은 합의 대상이 아니라 계약이므로
|
||||
[extension-points.md](../extension-points.md)의 협의 목록에서 뺀다.
|
||||
- `format` 키워드의 검증 강도와 허용 keyword 범위는 여전히 미확정이며 협의 목록에 남는다.
|
||||
- 새 참조 keyword가 JSON Schema에 추가되면 이 결정을 함께 갱신한다. 규칙은
|
||||
`ToolSchemaReferencePolicy`가 소유하고 `ToolSchemaReferencePolicyTest`가 잠근다.
|
||||
88
docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md
Normal file
88
docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# ADR-0012 Tool inputSchema의 정규식에 예산을 둔다
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-18
|
||||
- 관련 결정: [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) · [ADR-0006](ADR-0006-no-authentication-in-mcp.md) · [ADR-0004](ADR-0004-execution-guardrails.md)
|
||||
|
||||
## 배경
|
||||
|
||||
`inputSchema`의 `pattern` 검증은 `java.util.regex`로 처리된다. `com.networknt:json-schema-validator`의
|
||||
ECMAScript 엔진은 joni나 graal-js가 있을 때만 쓰이는데 둘 다 해석하지 않으므로
|
||||
([SBOM](../sbom/README.md)), 기본 경로인 `JDKRegularExpression`이 `Pattern.compile` 후 `Matcher.find()`를
|
||||
호출한다. `matches()`가 아니라 `find()`라서 모든 시작 위치를 시도한다.
|
||||
|
||||
이 엔진은 백트래킹 기반이라 정규식과 입력의 조합에 따라 처리 시간이 폭증한다. 정규식은 Tool Service
|
||||
매니페스트에서 오고 입력은 Agent Builder에서 오며, [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라
|
||||
호출자를 걸러 주는 계층이 없다. 한 요청이 스레드를 붙잡으면 그대로 Tomcat 스레드 고갈로 이어진다.
|
||||
|
||||
## 측정
|
||||
|
||||
규칙을 감으로 정하지 않기 위해 JDK 21.0.11에서 직접 재어 보았다. 3초 안에 끝나지 않으면 HANG으로 적었다.
|
||||
|
||||
| 정규식 | n=100 | n=1000 | n=10000 |
|
||||
|---|---|---|---|
|
||||
| `a*a*b` (무한 수량자 2개) | 4ms | 481ms | **HANG** |
|
||||
| `a*a*a*b` (3개) | 17ms | **HANG** | **HANG** |
|
||||
| `a*a*a*a*b` (4개) | 432ms | **HANG** | **HANG** |
|
||||
| `a*a*a*a*a*b` (5개) | **HANG** | **HANG** | **HANG** |
|
||||
| `(.*,){11}P` | **HANG** | **HANG** | **HANG** |
|
||||
| `(x+x+)+y` | 5ms | **HANG** | **HANG** |
|
||||
| `^[^@ ]+@[^@ ]+$` (무한 수량자 2개) | 4ms | 4ms | **4ms** |
|
||||
| `^([A-Z]{3}-)+[0-9]+$` | 1ms | 1ms | 1ms |
|
||||
|
||||
두 가지가 드러났다.
|
||||
|
||||
**첫째, 교과서적인 중첩 수량자는 생각보다 덜 위험하고 다른 형태가 더 위험하다.** `^(a+)+$`는 n=60에서도
|
||||
0ms로 끝났다. 반면 중첩이 아닌 `a*a*a*a*a*b`는 n=100에서 이미 멈췄고, 바깥 반복이 11회로 **묶여 있는**
|
||||
`(.*,){11}P`도 멈췄다. "중첩된 무한 수량자만 막으면 된다"는 통념대로 짰다면 정작 위험한 것을 놓쳤을 것이다.
|
||||
|
||||
**둘째, 개수만으로는 가를 수 없다.** `a*a*b`와 `^[^@ ]+@[^@ ]+$`는 둘 다 무한 수량자가 2개인데 전자는
|
||||
멈추고 후자는 n=10000에서도 4ms다. 차이는 수량자가 **겹치는 문자 집합**에 걸리느냐다. `@`가 경계를 만들면
|
||||
되돌아갈 여지가 없다. 겹침 판정은 정적 분석 대상이고 일반적으로 결정 불가능하다.
|
||||
|
||||
## 결정
|
||||
|
||||
정규식 모양만으로는 안전을 가릴 수 없으므로, **가릴 수 있는 것은 모양으로 막고 나머지는 입력 길이로 묶는다.**
|
||||
검사는 `ToolSchemaPatternPolicy`가 `ToolMetadata` 생성 시점에 수행한다.
|
||||
|
||||
| 규칙 | 내용 | 근거 |
|
||||
|---|---|---|
|
||||
| 그룹 반복 | 무한 수량자를 품은 그룹을 다시 반복하면 거부. 바깥 반복 횟수에 상한이 있어도 거부 | `(x+x+)+y`, `(.*,){11}P` |
|
||||
| 수량자 개수 | 무한 수량자 4개 이상이면 거부 | 4개는 n=1000, 5개는 n=100에서 멈춤 |
|
||||
| 길이 상한 | `pattern`을 선언한 필드는 `maxLength`를 함께 선언해야 하고 256 이하여야 함 | 비용이 입력 길이를 따라 늘어남 |
|
||||
| 정규식 길이 | 512자 이하 | 분석 비용을 함께 묶음 |
|
||||
| 컴파일 | 등록 시점에 `Pattern.compile` | 잘못된 정규식이 요청 시점에 터지지 않게 |
|
||||
| `patternProperties` | 사용 금지 | 아래 참조 |
|
||||
|
||||
`maxLength` 요구가 이 결정의 핵심이다. 나머지 규칙은 겹침을 판정하지 못하므로, 실질적인 상한은 길이 제한이
|
||||
만든다. 상한이 없으면 요청 body 한도(약 1MB)까지 열린다.
|
||||
|
||||
한도 값은 설정으로 열지 않고 상수로 둔다. [ADR-0006](ADR-0006-no-authentication-in-mcp.md)의 NetworkPolicy와
|
||||
같은 이유다. values 한 줄로 사라질 수 있는 통제는 통제가 아니다.
|
||||
|
||||
## 이 결정이 하지 않는 것
|
||||
|
||||
**안전을 증명하지 않는다.** 무한 수량자 3개 이하이면서 문자 집합이 겹치는 정규식은 통과하고, `maxLength`가
|
||||
256이면 그 조합에서 수백 ms가 걸릴 수 있다. 이 결정은 위험을 없애지 않고 **측정된 폭증 구간 밖으로 옮긴다.**
|
||||
|
||||
근본적인 해결은 백트래킹하지 않는 엔진(RE2 계열)으로 바꾸거나 검증에 시간 예산을 두는 것이다. 둘 다 지금
|
||||
채택하지 않았다. 전자는 폐쇄망 반입 대상 의존성이 늘고 networknt가 그 엔진을 지원하는지 확인해야 하며,
|
||||
후자는 `java.util.regex`가 인터럽트에 반응하지 않아 검증기 내부에 우리 `CharSequence`를 넣을 수 없으면
|
||||
스레드를 버리는 방식이 된다. 필요가 생기면 이 ADR을 대체하는 새 ADR을 먼저 쓴다.
|
||||
|
||||
## 영향
|
||||
|
||||
- **Tool Service는 `pattern`을 쓰는 문자열 필드에 `maxLength`(≤256)를 함께 선언해야 한다.** 이는 매니페스트
|
||||
수용 조건의 변경이므로 Tool Service 파트와 합의가 필요하다. 현재 저장소의 schema 중 `pattern`을 쓰는 것은
|
||||
없어 기존 fixture는 영향을 받지 않는다.
|
||||
- **`patternProperties`는 쓸 수 없다.** 이 keyword는 값이 아니라 입력 객체의 **key**에 정규식을 적용하는데,
|
||||
key에는 길이를 선언할 자리가 없어 위의 `maxLength` 방식을 그대로 적용할 수 없다. `propertyNames`로 key 길이를
|
||||
묶는 방법을 검토했으나, JSON Schema는 keyword 평가 순서를 정하지 않으므로 `propertyNames`가 먼저 돈다는
|
||||
보장이 없다. 순서에 기대는 통제는 검증기 구현이 바뀌면 조용히 사라진다.
|
||||
|
||||
현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 위험을 남겨 두는 대신 쓰지 않는 기능을 닫는다.
|
||||
이는 [ADR-0006](ADR-0006-no-authentication-in-mcp.md)이 검증하지 않는 인증 코드를 지운 것과 같은 판단이다.
|
||||
동적 key가 실제로 필요해지면 key 길이를 묶는 방법을 정한 새 ADR을 먼저 쓴다. 부작용으로, `patternProperties`
|
||||
라는 이름의 업무 필드를 가진 schema도 거부된다. 실제로 나타날 가능성이 낮아 감수한다.
|
||||
- 규칙과 한도는 `ToolSchemaPatternPolicy`가 소유하고 `ToolSchemaPatternPolicyTest`가 잠근다. 위 측정을 다시
|
||||
하지 않고 한도를 바꾸지 않는다.
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-0013 Tool Server endpoint 목록과 route 매핑의 원천은 Portal이 소유한다
|
||||
|
||||
- 상태: Accepted
|
||||
- 결정일: 2026-08-22
|
||||
- 대체 결정: [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) 전체, [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) 결정 4
|
||||
- 관련: [ADR-0001](ADR-0001-stateless-execution-boundary.md) · [ADR-0005](ADR-0005-standard-tool-name.md) · [ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md) · [Portal-MCP 계약 v0.1](../contracts/portal-mcp/protocol-v0.1-registry.md)
|
||||
|
||||
## 배경
|
||||
|
||||
[ADR-0007](ADR-0007-one-mcp-per-tool-service.md)은 MCP 배포 하나가 Tool Service 하나만 보게 하고, 어떤 Tool Service를 볼지를 `mcp.bundles`에 배포 시점으로 못박았다. 전제는 **매핑이 배포 시점에 확정된다**는 것이었다.
|
||||
|
||||
내부망 운영은 그 전제를 따르지 않는다. route와 Tool Service의 매핑은 Portal이 관리하고, MCP는 기동 preload와 주기 refresh에서 Portal registry를 읽어 매핑을 받는다. 매핑이 바뀌어도 MCP를 다시 배포하지 않아야 한다.
|
||||
|
||||
구현은 이미 이 구조였다. `PortalToolRegistryClient`가 registry 응답을 `Map<String, List<Bundle>>`(route → Tool Service 목록)로 유지하고, `McpRequestContextFactory`가 `/mcp/{routeKey}`에서 route를 뽑고, `ToolRegistryService`가 route별 snapshot을 들고 있다. 그런데 이 경로를 정당화하는 결정 문서가 없었고, 그 사이 ADR-0007은 `Accepted` 상태로 남아 코드와 정반대되는 내용을 현재 설계 근거처럼 제시하고 있었다.
|
||||
|
||||
## 결정
|
||||
|
||||
1. **Tool Server endpoint 목록과 route↔Tool Service 매핑의 원천은 Portal이다.** MCP는 기동 preload와 주기 refresh에서 Portal registry를 조회한다.
|
||||
2. **MCP 배포 하나가 N개 route를 서비스한다.** route key는 `/mcp/{routeKey}` URI에서만 결정한다.
|
||||
3. **route 하나에 N개 Tool Service가 붙을 수 있다.** 카탈로그 병합 단위는 route다.
|
||||
4. Portal은 **주소만** 소유한다. Tool 목록·schema·timeout은 Tool Service 매니페스트가 소유하고, Tool 실행 주소도 매니페스트가 정한다([ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md)).
|
||||
5. 요청 경로(`tools/list`, `tools/call`, route key 검증)는 in-memory snapshot만 읽는다. Portal은 요청 경로에 없다.
|
||||
6. 응답 모양과 실패 처리는 [Portal-MCP 계약 v0.1](../contracts/portal-mcp/protocol-v0.1-registry.md)이 정본이다.
|
||||
|
||||
## 근거
|
||||
|
||||
### 매핑이 동적이면 배포 축과 매핑 축을 겹칠 수 없다
|
||||
|
||||
ADR-0007은 매핑을 배포 정의에 넣었다. Portal이 매핑을 소유하는 순간 **매핑 변경이 곧 배포 변경**이 되어 Portal을 원천으로 둔 의미가 사라진다. 원천이 Portal이면 배포는 매핑에 대해 중립이어야 하고, 그래서 한 배포가 N route를 서비스한다.
|
||||
|
||||
### ADR-0007의 격리 논거는 층위별로 다르게 남는다
|
||||
|
||||
ADR-0007이 지키려던 것은 가용성 등급별 격리였다. 이 구조에서 무엇이 남고 무엇이 사라지는지 숨기지 않고 적는다. 아래는 현재 main 코드 기준이다.
|
||||
|
||||
| 층위 | 격리 | 근거 |
|
||||
|---|---|---|
|
||||
| route별 snapshot 보관 | **유지** | `ToolRegistryService`가 route별 snapshot을 따로 들고, 요청은 자기 route만 읽는다 |
|
||||
| route 안 N개 Tool Service의 **조회** | **유지** | `ToolBundleDiscovery`가 bundle마다 last-good을 따로 보관한다 |
|
||||
| route 안 카탈로그 **교체** | **없음** | 사용 가능한 성공본이 없는 Tool Service가 하나라도 있으면 그 route 전체 교체를 거부한다 |
|
||||
| route 간 **갱신** | **유지** | `PortalToolRegistryClient.fetchAllTools()`가 route마다 `fetchRouteToolsSafely()`로 예외를 격리하고 실패한 route만 결과에서 뺀다 |
|
||||
| 프로세스 자원(connection pool, thread, heap) | **없음** | 전 route가 공유한다 |
|
||||
| 배포·재기동·프로세스 장애 | **없음** | 전 route가 동시에 영향을 받는다 |
|
||||
|
||||
**ADR-0007이 지키려던 가용성 등급별 물리 분리는 이 구조에서 성립하지 않는다.** 등급 요구가 다시 생기면 이 ADR을 재검토한다(전제 2).
|
||||
|
||||
## 전제
|
||||
|
||||
아래가 깨지면 이 결정을 재검토한다.
|
||||
|
||||
1. route↔Tool Service 매핑의 관리 주체는 Portal이며, 매핑 변경이 MCP 재배포 없이 반영되어야 한다.
|
||||
2. 가용성 등급별 물리 분리 요구가 없다.
|
||||
3. 전 route의 Tool 총량과 매니페스트 조회 부하를 한 프로세스가 감당한다.
|
||||
4. Portal은 신뢰 경계 안에 있고 공개 네트워크에 노출되지 않는다.
|
||||
|
||||
## 영향
|
||||
|
||||
- route 없는 `/mcp` 호출은 `route key is required`로 거부된다. Agent Builder에는 route별 URL만 등록한다.
|
||||
- 등록되지 않은 route는 `McpRouteKeyValidator`가 controller 진입 전에 거부한다. 판단은 memory snapshot만 본다.
|
||||
- Tool 이름 유일성은 **route 안에서만** 검사한다. 서로 다른 route에 같은 이름이 있어도 거부하지 않는다.
|
||||
- `mcp.discovery.max-tools-total`은 전역이 아니라 **route 단위 상한**으로 동작한다. `merge()`가 route마다 호출되기 때문이다.
|
||||
- Portal 조회 실패는 목록을 비우지 않는다. memory를 유지하고, cold start일 때만 `mcp.redis.portal-registry-key`의 Redis fallback을 읽는다.
|
||||
- refresh 실패는 애플리케이션을 죽이지 않는다. `ToolRegistryRefreshScheduler`가 `RuntimeException`을 잡아 warn 로그만 남긴다.
|
||||
- [ADR-0009](ADR-0009-container-handles-public-mcp-path.md)의 "공개 path를 rewrite하지 않고 컨테이너가 직접 처리한다"는 유지된다. 다만 고정 `publicPath` 대신 `/mcp` + 동적 route로 처리하므로 결정 4만 이 ADR이 대체한다.
|
||||
- [ADR-0002](ADR-0002-tool-exposure-and-single-call.md)의 Tool 노출 상한 50개는 Agent 기준 합계이므로 바뀌지 않는다.
|
||||
|
||||
## 남은 위험
|
||||
|
||||
이 결정을 확정하면서 코드가 아직 따라오지 못한 지점이다. 둘 다 이 ADR의 의도와 어긋나므로 기록해 둔다.
|
||||
|
||||
처음 이 문서를 쓸 때 적었던 "route 간 갱신 격리 없음"은 `6653030 Isolate route manifest failures during tool preload`으로 해소되어 위 격리 표로 옮겼다.
|
||||
|
||||
1. **warm start가 Portal 모드에서 동작하지 않는다.** `ToolRegistryService.warmStartFromSharedCache()`는 route `""`의 Redis key만 읽는다. route가 이름을 갖는 이 구성에서는 아무것도 읽지 못해, 기동 직후 빈 목록 구간을 줄이는 효과가 사라진다.
|
||||
2. **readiness가 route별 상태를 노출하지 않는다.** `ToolCatalogHealthIndicator`는 `usableSnapshot`만 detail로 내보낸다. 어느 route가 준비됐고 어느 route가 비어 있는지 관제가 알 수 없다.
|
||||
|
||||
## 남은 판단
|
||||
|
||||
- `deploy/helm/`의 배포별 topology와 `HelmDeploymentContractTest`는 `mcp.bundles` 기반 1:1 구성의 계약이다. 코드에 그 경로가 남아 있어 local 검증과 1:1 배포에서는 유효하지만, 내부망 운영 대상인지 여부는 이 ADR이 정하지 않는다.
|
||||
- readiness를 route 단위로 세분화할지는 운영 관측 이후에 다시 본다.
|
||||
|
||||
## 채택하지 않은 대안
|
||||
|
||||
**ADR-0007을 유지하고 배포마다 자기 route만 조회한다.** 격리는 지키지만 route 추가가 배포 추가가 된다. Portal이 route 목록의 원천인데 배포 topology가 그 목록을 따라가야 하므로 순환이 생긴다.
|
||||
|
||||
**`mcp.bundles`에 매핑을 하드코딩한다.** 매핑 변경마다 재배포가 필요해 전제 1과 충돌한다.
|
||||
|
||||
**route별로 프로세스를 나누고 각자 Portal을 조회한다.** 자원 격리는 얻지만 Portal이 route 목록을 소유하는 이상 배포 수를 Portal이 정하게 되어, 운영 중 route 추가가 배포 파이프라인을 건드린다.
|
||||
@@ -18,6 +18,10 @@
|
||||
| [ADR-0004](ADR-0004-execution-guardrails.md) | 300초, Raw Data, unsafe retry 실행 가드레일 | Accepted |
|
||||
| [ADR-0005](ADR-0005-standard-tool-name.md) | 표준 MCP Tool name을 실행 식별자로 사용 | Accepted |
|
||||
| [ADR-0006](ADR-0006-no-authentication-in-mcp.md) | MCP Server는 인증·인가를 하지 않는다 | Accepted |
|
||||
| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Accepted |
|
||||
| [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Superseded |
|
||||
| [ADR-0008](ADR-0008-shared-host-path-routing.md) | 공유 host의 path를 독립 MCP 배포로 연결 | Superseded |
|
||||
| [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) | 컨테이너가 공개 MCP path를 직접 처리 | Accepted |
|
||||
| [ADR-0010](ADR-0010-tool-service-manifest-owns-execution-endpoint.md) | Tool 실행 endpoint를 Tool Service 매니페스트가 선언 | Accepted |
|
||||
| [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) | Tool inputSchema는 문서 밖을 참조하지 않는다 | Accepted |
|
||||
| [ADR-0012](ADR-0012-tool-input-schema-pattern-budget.md) | Tool inputSchema의 정규식에 예산을 둔다 | Accepted |
|
||||
| [ADR-0013](ADR-0013-portal-owns-route-and-endpoint-registry.md) | Tool Server endpoint 목록과 route 매핑의 원천은 Portal | Accepted |
|
||||
|
||||
706
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json
Normal file
706
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json
Normal file
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:dd2dbf54-3ff5-57b2-bc28-765721359457",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-08-18T00:00:00Z",
|
||||
"component": {
|
||||
"type": "application",
|
||||
"bom-ref": "pkg:maven/io.shinhanlife.dap.biz.mcp/ax-hub-mcp-server@0.1.0",
|
||||
"group": "io.shinhanlife.dap.biz.mcp",
|
||||
"name": "ax-hub-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "AXHUB MCP&Tool Service 공통 스택 (Java 21 / Spring Boot 3.5.11)",
|
||||
"purl": "pkg:maven/io.shinhanlife.dap.biz.mcp/ax-hub-mcp-server@0.1.0"
|
||||
},
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:scope",
|
||||
"value": "MCP Java SDK 2.0.0과 그 런타임 전이 의존, 그리고 빌드 환경. MCP Server와 Tool Service의 공통 스택에 적용된다"
|
||||
},
|
||||
{
|
||||
"name": "axhub:source",
|
||||
"value": "build.gradle + Gradle 로컬 캐시의 실제 pom/jar 판독"
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0",
|
||||
"name": "mcp-json-jackson2",
|
||||
"version": "2.0.0",
|
||||
"publisher": "Anthropic",
|
||||
"description": "MCP JSON 직렬화 · JSON Schema 2020-12 검증 구현체",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0",
|
||||
"group": "io.modelcontextprotocol.sdk",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "2f9b7d72acb74d854589b7f22477aaaef4d84083"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "58951bd4b1c5a385af5b146b5582bc457475e2933a220d2fd63c1aed4435d1fc6f585b068dc752170d58890bd4036947c82afa8686f872560c2d148d270654fe"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT",
|
||||
"url": "https://opensource.org/licenses/MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/modelcontextprotocol/java-sdk"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "직접 선언 (build.gradle implementation)"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "이 SBOM의 유일한 직접 선언 오픈소스"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
|
||||
"name": "mcp-core",
|
||||
"version": "2.0.0",
|
||||
"publisher": "Anthropic",
|
||||
"description": "MCP 표준 프로토콜 모델(McpSchema) · JSON-RPC 상수",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
|
||||
"group": "io.modelcontextprotocol.sdk",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "fd49feda3b9e6914a46a56ccd4a8f70e35156898"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "44dcf26bddfaa4757d7b2d765cd48745a0130fbc074ebb59565a03f66c92f387073d109c54fe62e1a68f3df71629928df973f6adc8abe75d4f5cf76b1d1f6f0b"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT",
|
||||
"url": "https://opensource.org/licenses/MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/modelcontextprotocol/java-sdk"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← mcp-json-jackson2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/com.networknt/json-schema-validator@2.0.0",
|
||||
"name": "json-schema-validator",
|
||||
"version": "2.0.0",
|
||||
"publisher": "Network New Technologies Inc.",
|
||||
"description": "JSON Schema draft 2020-12 검증 엔진 (Tool inputSchema 검증)",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/com.networknt/json-schema-validator@2.0.0",
|
||||
"group": "com.networknt",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "bc7c4ddf322d1295e3c296f28a9966590e6dea20"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "bc033e50c66e72ad89df6442532b614fc984386aad2da68daaa098d81ac5a4a82933d0783d3f1a0ed5fe80e3bca6091d72acf5d7dcadcb50c3153100edcf334b"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/networknt/json-schema-validator"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← mcp-json-jackson2"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "optional인 joni·graal-js를 해석하지 않아 pattern 검증에 JDK 정규식 엔진을 사용한다"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/com.ethlo.time/itu@1.14.0",
|
||||
"name": "itu",
|
||||
"version": "1.14.0",
|
||||
"publisher": "ethlo (Morten Haraldsen)",
|
||||
"description": "RFC 3339 date/date-time 파싱 — json-schema-validator의 format 구현용",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/com.ethlo.time/itu@1.14.0",
|
||||
"group": "com.ethlo.time",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "c0f9f9d4f4404787e992ab3af5ae95f2fad79e47"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "aa69a6af3a7123eb41425bbaf6834e16dc3323172709e2338b8a21b970fd21333d996515f42da4aa0225251e30542ad7d9c8332bdf7d62ed96b42fadc8a1520d"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/ethlo/itu"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← json-schema-validator"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "SDK 검증기가 format을 단언하지 않아 런타임에 호출되지 않는다. classpath에는 포함되므로 수록"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
|
||||
"name": "jackson-dataformat-yaml",
|
||||
"version": "2.19.4",
|
||||
"publisher": "FasterXML, LLC",
|
||||
"description": "YAML 형식 schema 로딩 (validator 부가 기능)",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
|
||||
"group": "com.fasterxml.jackson.dataformat",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "500956daea0869bf753b94fdaa77e5dc99847d79"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "42cf2edacf2dea3c0616991a9a945c6e3e44dcb719918e76e6babae55601454397a1667bf75b7d55c74f96a7da7c0d9f60a0f4be60f84fd405fa31eb144f9b92"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/FasterXML/jackson-dataformats-text"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← json-schema-validator"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "Spring Boot 3.5.11 BOM이 2.19.4로 정렬"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/io.projectreactor/reactor-core@3.7.16",
|
||||
"name": "reactor-core",
|
||||
"version": "3.7.16",
|
||||
"publisher": "VMware (Project Reactor)",
|
||||
"description": "mcp-core가 참조하는 리액티브 타입 제공",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/io.projectreactor/reactor-core@3.7.16",
|
||||
"group": "io.projectreactor",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "f0313eedd03acee06e7e38a915ecb8060d6996ffafbd05afeff4c7cdeb239e022b65f8f721290e228d5c30180d069a417cb40c3f782b643508fa0b64d11de10f"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/reactor/reactor-core"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← mcp-core"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "pom 요청 3.7.0 → reactor-bom 2024.0.15의 3.7.16"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/org.reactivestreams/reactive-streams@1.0.4",
|
||||
"name": "reactive-streams",
|
||||
"version": "1.0.4",
|
||||
"publisher": "Reactive Streams SIG",
|
||||
"description": "리액티브 스트림 표준 인터페이스",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/org.reactivestreams/reactive-streams@1.0.4",
|
||||
"group": "org.reactivestreams",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "3864a1320d97d7b045f729a326e1e077661f31b7"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "cdab6bd156f39106cd6bbfd47df1f4b0a89dc4aa28c68c31ef12a463193c688897e415f01b8d7f0d487b0e6b5bd2f19044bf8605704b024f26d6aa1f4f9a2471"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT-0",
|
||||
"url": "https://spdx.org/licenses/MIT-0.html"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "http://www.reactive-streams.org/"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← reactor-core"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
|
||||
"name": "jackson-databind",
|
||||
"version": "2.19.4",
|
||||
"publisher": "FasterXML, LLC",
|
||||
"description": "JSON 데이터 바인딩",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
|
||||
"group": "com.fasterxml.jackson.core",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "7a39bf9257b726b90b80f27fa3f5174bc75162a5"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "02a80c97ea12874f66802cb2c8909e5358639b41050bd04da495c0ee8db496a0d9d609a3c62a1dca7cbd89681bf340d6f6dbc507d4f21602aa1a7f31b2285ba8"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/FasterXML/jackson-databind"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← mcp-json-jackson2, json-schema-validator"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "pom 요청 2.20.1 / 2.18.3 → Spring Boot 3.5.11 BOM의 2.19.4로 정렬"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
|
||||
"name": "jackson-core",
|
||||
"version": "2.19.4",
|
||||
"publisher": "FasterXML, LLC",
|
||||
"description": "JSON 스트리밍 파서/생성기",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
|
||||
"group": "com.fasterxml.jackson.core",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "a720ca9b800742699e041c3890f3731fe516085e"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "987de559d452fb78557c038a02289454cf1354985bdb79df1087c5bc33db35c9510ee6c1c1dd3816e220a86a35d19820a8c32176a7d4fc4e5d3c7e65df5536d4"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/FasterXML/jackson-core"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← jackson-databind"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
|
||||
"name": "jackson-annotations",
|
||||
"version": "2.19.4",
|
||||
"publisher": "FasterXML, LLC",
|
||||
"description": "JSON 바인딩 애노테이션",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
|
||||
"group": "com.fasterxml.jackson.core",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "bbb09b1e7f7f5108890270eb701cb3ddef991c05"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "22a2ce8150c380b9dc00bfbdd026f26e626f483e8ceebfbb2087e9abd63462781daf4e18ca09543a7d0eb7b5c5625f02332d3251e29c2abc6016d69a7194a565"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/FasterXML/jackson-annotations"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← mcp-core, jackson-databind"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/org.slf4j/slf4j-api@2.0.17",
|
||||
"name": "slf4j-api",
|
||||
"version": "2.0.17",
|
||||
"publisher": "QOS.ch",
|
||||
"description": "로깅 파사드",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/org.slf4j/slf4j-api@2.0.17",
|
||||
"group": "org.slf4j",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "d9e58ac9c7779ba3bf8142aff6c830617a7fe60f"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "9a3e79db6666a6096a3021bb2e1d918f30f589d8de51d6b600f8ebd92515a510ae2d8f87919cc2dfa8365d64f10194cac8dfa0fb950160eef0e9da06f6caaeb9"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT",
|
||||
"url": "https://opensource.org/licenses/MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://www.slf4j.org/"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← mcp-core, json-schema-validator"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "pom 요청 2.0.16 → Spring Boot 3.5.11 BOM의 2.0.17로 정렬"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bom-ref": "pkg:maven/org.yaml/snakeyaml@2.4",
|
||||
"name": "snakeyaml",
|
||||
"version": "2.4",
|
||||
"publisher": "SnakeYAML",
|
||||
"description": "YAML 파서 (jackson-dataformat-yaml 백엔드)",
|
||||
"scope": "required",
|
||||
"purl": "pkg:maven/org.yaml/snakeyaml@2.4",
|
||||
"group": "org.yaml",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-1",
|
||||
"content": "e0666b825b796f85521f02360e77f4c92c5a7a07"
|
||||
},
|
||||
{
|
||||
"alg": "SHA-512",
|
||||
"content": "1573717e2c47868515cbed5265a6f77ebec23a0b5c6376ac18b9f5c2335beb65d4c68d2073d50143d59a60141980be8db1e493a85d7c78106cdb94a52e8361d2"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://bitbucket.org/snakeyaml/snakeyaml"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "전이 ← jackson-dataformat-yaml"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "platform",
|
||||
"bom-ref": "pkg:generic/jdk@21.0.5",
|
||||
"name": "jdk",
|
||||
"version": "21.0.5",
|
||||
"publisher": "Eclipse Adoptium (Temurin)",
|
||||
"description": "언어/실행 환경 — Java 21 toolchain",
|
||||
"scope": "optional",
|
||||
"purl": "pkg:generic/jdk@21.0.5",
|
||||
"licenses": [
|
||||
{
|
||||
"expression": "GPL-2.0-only WITH Classpath-exception-2.0"
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://adoptium.net/temurin/releases/?version=21"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "build.gradle java.toolchain (vendor=ADOPTIUM)"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "표준가이드의 openjdk21u-jdk_x64_windows_hotspot_21.0.5 기준. Classpath Exception이 있어 이 JDK로 실행하는 애플리케이션에는 소스 공개 의무가 없다"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "application",
|
||||
"bom-ref": "pkg:generic/gradle@8.14.3",
|
||||
"name": "gradle",
|
||||
"version": "8.14.3",
|
||||
"publisher": "Gradle Inc.",
|
||||
"description": "빌드 도구 (gradle wrapper 고정)",
|
||||
"scope": "optional",
|
||||
"purl": "pkg:generic/gradle@8.14.3",
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-256",
|
||||
"content": "bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531"
|
||||
}
|
||||
],
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://gradle.org/"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "axhub:dependencyPath",
|
||||
"value": "gradle/wrapper/gradle-wrapper.properties"
|
||||
},
|
||||
{
|
||||
"name": "axhub:note",
|
||||
"value": "SHA-256은 wrapper의 distributionSha256Sum 값"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{
|
||||
"ref": "pkg:maven/io.shinhanlife.dap.biz.mcp/ax-hub-mcp-server@0.1.0",
|
||||
"dependsOn": [
|
||||
"pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-json-jackson2@2.0.0",
|
||||
"dependsOn": [
|
||||
"pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
|
||||
"pkg:maven/com.networknt/json-schema-validator@2.0.0",
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/io.modelcontextprotocol.sdk/mcp-core@2.0.0",
|
||||
"dependsOn": [
|
||||
"pkg:maven/io.projectreactor/reactor-core@3.7.16",
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
|
||||
"pkg:maven/org.slf4j/slf4j-api@2.0.17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/com.networknt/json-schema-validator@2.0.0",
|
||||
"dependsOn": [
|
||||
"pkg:maven/com.ethlo.time/itu@1.14.0",
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
|
||||
"pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
|
||||
"pkg:maven/org.slf4j/slf4j-api@2.0.17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/com.ethlo.time/itu@1.14.0",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml@2.19.4",
|
||||
"dependsOn": [
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
|
||||
"pkg:maven/org.yaml/snakeyaml@2.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/io.projectreactor/reactor-core@3.7.16",
|
||||
"dependsOn": [
|
||||
"pkg:maven/org.reactivestreams/reactive-streams@1.0.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/org.reactivestreams/reactive-streams@1.0.4",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.19.4",
|
||||
"dependsOn": [
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.19.4",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-annotations@2.19.4",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/org.slf4j/slf4j-api@2.0.17",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:maven/org.yaml/snakeyaml@2.4",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:generic/jdk@21.0.5",
|
||||
"dependsOn": []
|
||||
},
|
||||
{
|
||||
"ref": "pkg:generic/gradle@8.14.3",
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx
Normal file
BIN
docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx
Normal file
Binary file not shown.
74
docs/sbom/README.md
Normal file
74
docs/sbom/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# SBOM — AXHUB MCP&Tool Service
|
||||
|
||||
- 산출물: `AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json` (CycloneDX 1.5 정본), `AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx` (검토용)
|
||||
- 대상: AXHUB MCP Server와 Tool Service의 공통 스택 (Java 21 / Spring Boot 3.5.11)
|
||||
- 산출 기준 빌드: `ax-hub-mcp-server@0.1.0`
|
||||
- 생성 기준일: 2026-08-18
|
||||
|
||||
## 대상 범위
|
||||
|
||||
MCP Server와 Tool Service는 같은 기술 스택과 같은 MCP SDK를 쓰므로 이 SBOM을 공통으로 적용한다.
|
||||
`build.gradle`이 직접 선언한 오픈소스는 `io.modelcontextprotocol.sdk:mcp-json-jackson2:2.0.0`
|
||||
하나이며, 이 SBOM은 그 **런타임 전이 의존 전체**와 **빌드 환경**을 담는다. Spring Boot starter
|
||||
계열(web / validation / data-redis / actuator)은 glow f/w가 제공하는 플랫폼 구성이라 범위 밖이다.
|
||||
|
||||
다만 목록은 **MCP Server 빌드(`ax-hub-mcp-server@0.1.0`) 하나에서 산출했다.** Tool Service가 이
|
||||
스택 밖의 의존(예: DB 드라이버, 연계 라이브러리)을 추가하면 그만큼은 이 SBOM에 없으므로, 해당
|
||||
빌드에서 다시 산출해 합쳐야 한다.
|
||||
|
||||
| 구분 | 개수 | 내용 |
|
||||
|---|---|---|
|
||||
| 런타임 의존성 (scope: required) | 12 | 실행 산출물 classpath에 올라가는 라이브러리 |
|
||||
| 빌드 환경 (scope: optional) | 2 | JDK 21, Gradle 8.14.3 |
|
||||
| 합계 | 14 | |
|
||||
|
||||
라이선스는 Apache-2.0 9건, MIT 3건, MIT-0 1건, GPL-2.0 with Classpath Exception 1건(JDK)이다.
|
||||
라이브러리 12건은 모두 permissive이고, copyleft는 JDK 하나뿐이다. JDK는 Classpath Exception이
|
||||
있어 이 JDK로 실행하는 애플리케이션에 소스 공개 의무가 생기지 않는다.
|
||||
|
||||
JDK 배포판은 표준가이드가 정한 Eclipse Temurin
|
||||
(`openjdk21u-jdk_x64_windows_hotspot_21.0.5`)이며, `build.gradle`의 toolchain에
|
||||
`vendor = JvmVendorSpec.ADOPTIUM`으로 고정해 다른 배포판으로 빌드되지 않게 했다. 실행 컨테이너도
|
||||
같은 계열인 `eclipse-temurin:21-jre`를 쓴다.
|
||||
|
||||
## 제외 항목
|
||||
|
||||
제외 항목과 사유는 엑셀 `Exclusions` 시트가 정본이다. 요약하면 다음과 같다.
|
||||
|
||||
- **test scope와 annotationProcessor** — 선언 4건. 산출물에 포함되지 않는다.
|
||||
- `spring-boot-starter-test`, `com.squareup.okhttp3:mockwebserver:4.12.0`,
|
||||
`org.junit.platform:junit-platform-launcher` (test scope)
|
||||
- `org.springframework.boot:spring-boot-configuration-processor` (annotationProcessor)
|
||||
- `joni`, `graal-js`, `graal-sdk` — json-schema-validator의 `optional`. ECMA262 정규식 검증을
|
||||
쓰지 않아 해석되지 않으므로 약 50MB가 빠진다.
|
||||
- `jakarta.servlet-api:6.1.0` — mcp-core의 `provided`. 산출물에 포함되지 않고 서블릿 컨테이너가 제공한다.
|
||||
- `mcp:2.0.0`(aggregate), `mcp-json-jackson3:2.0.0` — Jackson 3 경로를 쓰지 않아 선언하지 않는다.
|
||||
자세한 배경은 [mcp-java-sdk-adoption.md](../mcp-java-sdk-adoption.md) 참고.
|
||||
|
||||
## 산출 방법과 한계
|
||||
|
||||
버전과 해시는 `build.gradle` 선언에서 출발해 Gradle 로컬 캐시의 실제 `pom`을 따라가 그래프를
|
||||
만들고, 캐시된 실제 jar 바이너리에서 SHA-512 / SHA-1을 직접 계산했다. Gradle 배포본의 SHA-256은
|
||||
wrapper의 `distributionSha256Sum` 값을 그대로 옮겼다.
|
||||
|
||||
`gradlew dependencies`로 해석 결과를 대조하려 했으나 sandbox에서 gradle daemon이 뜨지 않아
|
||||
(`Unable to establish loopback connection`) 실행하지 못했다. 따라서 다음 버전 정렬은 pom과
|
||||
Spring Boot BOM 판독에 근거한 것이며, 빌드 환경에서 한 번 확인해야 한다.
|
||||
|
||||
```bash
|
||||
./gradlew dependencies --configuration runtimeClasspath
|
||||
```
|
||||
|
||||
| 컴포넌트 | pom 요청 버전 | 수록 버전 | 근거 |
|
||||
|---|---|---|---|
|
||||
| jackson-databind | 2.20.1 (mcp-json-jackson2) | 2.19.4 | Spring Boot 3.5.11 → jackson-bom 2.19.4 |
|
||||
| jackson-databind | 2.18.3 (json-schema-validator) | 2.19.4 | 위와 동일 |
|
||||
| reactor-core | 3.7.0 (mcp-core) | 3.7.16 | Spring Boot 3.5.11 → reactor-bom 2024.0.15 |
|
||||
| slf4j-api | 2.0.16 (mcp-core) | 2.0.17 | Spring Boot 3.5.11 관리 버전 |
|
||||
|
||||
가장 확인이 필요한 항목은 jackson-databind다. SDK가 요청한 2.20.1이 `io.spring.dependency-management`에
|
||||
의해 2.19.4로 내려가므로, initialize / tools/list / tools/call 직렬화 계약 테스트로 동작을 확인한다.
|
||||
|
||||
CycloneDX 1.5 공식 JSON Schema 원본 대조는 폐쇄망이라 수행하지 않았다. 대신 생성 시점에
|
||||
`dependencies`의 모든 `ref` / `dependsOn`이 실재하는 `bom-ref`를 가리키는지, 컴포넌트가 빠짐없이
|
||||
`dependencies`에 등장하는지 구조 점검을 통과시켰다.
|
||||
@@ -30,6 +30,15 @@ public record ToolMetadata(
|
||||
JsonNode publicDefinition,
|
||||
boolean exactEndpoint) {
|
||||
|
||||
/**
|
||||
* 모든 생성 경로가 지나는 표준 생성자로, {@code inputSchema}가 문서 밖을 참조하지 않는지와 정규식이 빨리 끝나는지 확인합니다. Portal 매니페스트 파싱, local 파일 로딩, Redis snapshot
|
||||
* 역직렬화가 모두 여기를 지나므로 검사 지점이 하나로 모입니다. 위반 시 {@link IllegalStateException}을 던져 해당 Tool이 Registry에 올라가지 못하게 합니다.
|
||||
*/
|
||||
public ToolMetadata {
|
||||
ToolSchemaReferencePolicy.assertNoExternalReference(inputSchema);
|
||||
ToolSchemaPatternPolicy.assertPatternsTerminateQuickly(inputSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력값을 내부 처리 형식으로 변환합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package io.shinhanlife.dat.biz.mcp.registry;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dat.biz.mcp.registry
|
||||
* @className ToolSchemaPatternPolicy
|
||||
* @description Tool의 {@code inputSchema}가 요청 스레드를 오래 붙잡는 정규식 검증을 유발하지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 매니페스트·local 파일·Redis snapshot 중
|
||||
* 어느 경로로 들어온 schema든 같은 규칙을 통과하며, 요청 경로에는 비용을 더하지 않습니다.
|
||||
* *
|
||||
* * <p>MCP는 joni와 graal-js를 해석하지 않아 {@code pattern} 검증이 {@code java.util.regex}로 처리된다. 이 엔진은 백트래킹 기반이고 {@code Matcher.find()}로 모든 시작 위치를
|
||||
* 시도하므로, 특정 정규식과 긴 입력의 조합에서 처리 시간이 다항·지수적으로 늘어난다. 인증이 없는 경계(ADR-0006)라 호출 빈도를 줄여 주는 계층도 없다.
|
||||
* *
|
||||
* * <p>규칙은 측정에 근거하며 안전을 증명하지 않는다. 근거와 한계는
|
||||
* <a href="../../../../../../../../docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md">ADR-0012</a>에 있다.
|
||||
* @author k.s.m
|
||||
* @create 2026.09.15
|
||||
*
|
||||
* <pre>
|
||||
* ============ 개정이력 ============
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- ---------- ----------------
|
||||
* 2026.09.15 k.s.m 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
final class ToolSchemaPatternPolicy {
|
||||
|
||||
/**
|
||||
* 허용할 정규식 길이 상한입니다. 업무 schema가 이보다 긴 정규식을 쓰는 경우는 사실상 없습니다.
|
||||
*/
|
||||
private static final int MAX_PATTERN_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* 정규식 하나가 가질 수 있는 무한 수량자({@code *}, {@code +}, {@code {n,}}) 개수 상한입니다. 겹치는 문자 집합에 이런 수량자가 k개 이어지면 비용이 입력 길이의 k제곱으로 늘어납니다. 측정상 4개부터는
|
||||
* 아래 입력 상한 안에서도 초 단위로 넘어가고, 정상 업무 정규식이 3개를 넘는 경우는 드뭅니다.
|
||||
*/
|
||||
private static final int MAX_UNBOUNDED_QUANTIFIERS = 3;
|
||||
|
||||
/**
|
||||
* {@code pattern}을 선언한 문자열 필드가 함께 선언해야 하는 {@code maxLength}의 상한입니다. 비용이 입력 길이에 달려 있으므로, 길이를 묶는 것이 정규식 모양을 검사하는 것보다 확실합니다.
|
||||
*/
|
||||
private static final int MAX_PATTERNED_STRING_LENGTH = 256;
|
||||
|
||||
private ToolSchemaPatternPolicy() {}
|
||||
|
||||
/**
|
||||
* schema 전체를 훑어 {@code pattern} 정규식과 그 필드의 길이 제한을 검사하고, 길이를 묶을 수 없는 {@code patternProperties}는 거부합니다. schema가 없으면 통과시키고, 위반을 찾으면 해당 Tool을
|
||||
* 등록하지 못하도록 {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로
|
||||
* 적용됩니다.
|
||||
*/
|
||||
static void assertPatternsTerminateQuickly(JsonNode schema) {
|
||||
if (schema == null || schema.isNull()) {
|
||||
return;
|
||||
}
|
||||
Deque<JsonNode> pending = new ArrayDeque<>();
|
||||
pending.push(schema);
|
||||
while (!pending.isEmpty()) {
|
||||
JsonNode node = pending.pop();
|
||||
if (node.isObject()) {
|
||||
assertKeywordPattern(node);
|
||||
assertPatternPropertiesAbsent(node);
|
||||
}
|
||||
node.forEach(pending::push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 schema 객체의 {@code pattern}과 그 필드의 {@code maxLength}를 함께 검사합니다. 값이 문자열이 아니면 정규식이 아니라 {@code properties} 아래에 우연히 같은 이름을 쓴 필드
|
||||
* 정의이므로 건너뜁니다.
|
||||
*/
|
||||
private static void assertKeywordPattern(JsonNode node) {
|
||||
JsonNode pattern = node.get("pattern");
|
||||
if (pattern == null || !pattern.isTextual()) {
|
||||
return;
|
||||
}
|
||||
assertSafeRegex(pattern.asText());
|
||||
assertBoundedLength(node.get("maxLength"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규식이 걸린 문자열의 길이가 묶여 있는지 확인합니다. 검증 비용이 입력 길이를 따라 늘어나므로, 상한이 없으면 정규식 모양과 무관하게 요청 body 한도(약 1MB)까지 열려 버립니다.
|
||||
*/
|
||||
private static void assertBoundedLength(JsonNode maxLength) {
|
||||
if (maxLength == null || !maxLength.isIntegralNumber()) {
|
||||
throw new IllegalStateException("Tool inputSchema pattern requires maxLength on the same field");
|
||||
}
|
||||
if (maxLength.intValue() <= 0 || maxLength.intValue() > MAX_PATTERNED_STRING_LENGTH) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema maxLength with pattern must be at most " + MAX_PATTERNED_STRING_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code patternProperties}를 아예 거부합니다. 이 keyword는 값이 아니라 <em>입력 객체의 key</em>에 정규식을 적용하는데, key 길이를 선언할 자리가 없어 {@code pattern}에 쓴 길이
|
||||
* 상한 방식을 그대로 적용할 수 없습니다. {@code propertyNames}로 길이를 묶는 방법은 keyword 평가 순서가 명세에 정해져 있지 않아 정규식이 먼저 돌 수 있으므로 통제로 쓰지 않습니다.
|
||||
*
|
||||
* <p>현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 것을 남겨 두는 대신 쓰지 않는 기능을 닫습니다. 실제 필요가 생기면 길이를 묶는 방법을 정한 새 ADR을 먼저 씁니다.
|
||||
*/
|
||||
private static void assertPatternPropertiesAbsent(JsonNode node) {
|
||||
if (node.has("patternProperties")) {
|
||||
throw new IllegalStateException("Tool inputSchema must not use patternProperties");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규식 하나가 길이 상한과 컴파일 가능성을 만족하고, 반복 구조가 허용 범위인지 확인합니다. 컴파일을 여기서 해 두면 잘못된 정규식이 요청 시점이 아니라 등록 시점에 걸립니다.
|
||||
*/
|
||||
private static void assertSafeRegex(String regex) {
|
||||
if (regex.length() > MAX_PATTERN_LENGTH) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema pattern must be at most " + MAX_PATTERN_LENGTH + " characters");
|
||||
}
|
||||
try {
|
||||
Pattern.compile(regex);
|
||||
} catch (PatternSyntaxException exception) {
|
||||
throw new IllegalStateException("Tool inputSchema pattern is not a valid regular expression");
|
||||
}
|
||||
assertRepetitionIsBudgeted(regex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 가지 반복 구조를 거부합니다.
|
||||
*
|
||||
* <ol>
|
||||
* <li>무한 수량자를 품은 그룹을 다시 반복하는 형태. {@code (x+x+)+y}와 {@code (.*,){11}P}가 여기 해당하며, 바깥 반복 횟수에 상한이 있어도 측정상 폭증했으므로 {@code {11}} 같은
|
||||
* 유한 반복도 함께 막습니다.
|
||||
* <li>무한 수량자가 {@value #MAX_UNBOUNDED_QUANTIFIERS}개를 넘는 형태. {@code a*a*a*a*a*b}처럼 겹치는 문자 집합에 수량자가 이어지는 경우를 줄입니다.
|
||||
* </ol>
|
||||
*
|
||||
* <p>겹침 여부까지 판정하지는 않으므로 이 검사만으로 안전이 보장되지 않습니다. 실질적인 상한은 함께 적용하는 {@code maxLength} 제한이 만듭니다.
|
||||
*/
|
||||
private static void assertRepetitionIsBudgeted(String regex) {
|
||||
// 각 원소는 "지금까지 이 그룹 안에서 무한 수량자를 봤는가"다.
|
||||
Deque<Boolean> openGroups = new ArrayDeque<>();
|
||||
boolean insideCharacterClass = false;
|
||||
int unboundedQuantifiers = 0;
|
||||
int index = 0;
|
||||
while (index < regex.length()) {
|
||||
char current = regex.charAt(index);
|
||||
if (current == '\\') {
|
||||
// 이스케이프된 문자는 수량자도 그룹도 아니다.
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (insideCharacterClass) {
|
||||
insideCharacterClass = current != ']';
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (current == '[') {
|
||||
insideCharacterClass = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (current == '(') {
|
||||
openGroups.push(Boolean.FALSE);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (current == ')') {
|
||||
boolean groupHasUnbounded = !openGroups.isEmpty() && openGroups.pop();
|
||||
int unbounded = unboundedQuantifierLength(regex, index + 1);
|
||||
int bounded = unbounded > 0 ? 0 : boundedQuantifierLength(regex, index + 1);
|
||||
if (groupHasUnbounded && (unbounded > 0 || bounded > 0)) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema pattern must not repeat a group that already repeats without bound");
|
||||
}
|
||||
if (unbounded > 0) {
|
||||
unboundedQuantifiers++;
|
||||
markEnclosingGroup(openGroups);
|
||||
}
|
||||
index += 1 + unbounded + bounded;
|
||||
continue;
|
||||
}
|
||||
int unbounded = unboundedQuantifierLength(regex, index);
|
||||
if (unbounded > 0) {
|
||||
unboundedQuantifiers++;
|
||||
markEnclosingGroup(openGroups);
|
||||
index += unbounded;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (unboundedQuantifiers > MAX_UNBOUNDED_QUANTIFIERS) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema pattern must use at most "
|
||||
+ MAX_UNBOUNDED_QUANTIFIERS
|
||||
+ " unbounded quantifiers");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 열려 있는 그룹에 무한 수량자를 봤다고 기록합니다. 그룹 밖이면 중첩 판정 대상이 없습니다.
|
||||
*/
|
||||
private static void markEnclosingGroup(Deque<Boolean> openGroups) {
|
||||
if (!openGroups.isEmpty()) {
|
||||
openGroups.pop();
|
||||
openGroups.push(Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 위치에서 시작하는 무한 수량자({@code *}, {@code +}, {@code {n,}})의 길이를 반환하고, 아니면 0을 반환합니다.
|
||||
*/
|
||||
private static int unboundedQuantifierLength(String regex, int start) {
|
||||
if (start >= regex.length()) {
|
||||
return 0;
|
||||
}
|
||||
char current = regex.charAt(start);
|
||||
if (current == '*' || current == '+') {
|
||||
return 1;
|
||||
}
|
||||
if (current != '{') {
|
||||
return 0;
|
||||
}
|
||||
int close = regex.indexOf('}', start);
|
||||
if (close < 0) {
|
||||
return 0;
|
||||
}
|
||||
return regex.substring(start + 1, close).endsWith(",") ? close - start + 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 위치에서 시작하는 상한 있는 수량자({@code ?}, {@code {n}}, {@code {n,m}})의 길이를 반환하고, 아니면 0을 반환합니다.
|
||||
*/
|
||||
private static int boundedQuantifierLength(String regex, int start) {
|
||||
if (start >= regex.length()) {
|
||||
return 0;
|
||||
}
|
||||
if (regex.charAt(start) == '?') {
|
||||
return 1;
|
||||
}
|
||||
if (regex.charAt(start) != '{') {
|
||||
return 0;
|
||||
}
|
||||
int close = regex.indexOf('}', start);
|
||||
return close < 0 ? 0 : close - start + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package io.shinhanlife.dat.biz.mcp.registry;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
|
||||
/**
|
||||
* @package io.shinhanlife.dat.biz.mcp.registry
|
||||
* @className ToolSchemaReferencePolicy
|
||||
* @description Tool의 {@code inputSchema}가 문서 밖을 가리키는 참조를 담지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 Portal 매니페스트, local 파일, Redis snapshot 중 어느 경로로 들어온
|
||||
* schema든 같은 규칙을 통과합니다. JSON Schema 검증기는 문서 밖 참조를 만나면 그 주소로 직접 조회를 시도하므로, 매니페스트가 서버의 outbound 호출 대상을 정하는 통로가 되지 않도록 수신 시점에 끊습니다.
|
||||
* @author k.s.m
|
||||
* @create 2026.09.15
|
||||
*
|
||||
* <pre>
|
||||
* ============ 개정이력 ============
|
||||
* 수정일 수정자 수정내용
|
||||
* ---------- ---------- ----------------
|
||||
* 2026.09.15 k.s.m 최초생성
|
||||
*
|
||||
* </pre>
|
||||
*/
|
||||
final class ToolSchemaReferencePolicy {
|
||||
|
||||
/**
|
||||
* MCP가 사용하는 유일한 JSON Schema dialect입니다. 다른 dialect를 선언하면 검증기가 그 meta-schema를 외부에서 조회할 수 있습니다.
|
||||
*/
|
||||
private static final String SUPPORTED_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
/**
|
||||
* 참조 대상을 문서 안으로 한정하는 keyword입니다. 값이 {@code #}으로 시작하면 같은 문서 안의 위치를 가리킨다.
|
||||
*/
|
||||
private static final String[] REFERENCE_KEYWORDS = {"$ref", "$dynamicRef"};
|
||||
|
||||
private ToolSchemaReferencePolicy() {}
|
||||
|
||||
/**
|
||||
* schema 전체를 훑어 문서 밖을 가리키는 참조가 있으면 거부합니다. schema가 없으면 검증할 것이 없으므로 그대로 통과시키고, 위반을 찾으면 해당 Tool을 등록하지 못하도록
|
||||
* {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로 적용됩니다.
|
||||
* 적대적으로 깊게 중첩된 schema에서도 스택이 무너지지 않도록 재귀 대신 명시적 스택으로 순회합니다.
|
||||
*/
|
||||
static void assertNoExternalReference(JsonNode schema) {
|
||||
if (schema == null || schema.isNull()) {
|
||||
return;
|
||||
}
|
||||
Deque<JsonNode> pending = new ArrayDeque<>();
|
||||
pending.push(schema);
|
||||
while (!pending.isEmpty()) {
|
||||
JsonNode node = pending.pop();
|
||||
if (node.isObject()) {
|
||||
assertReferencesStayInDocument(node);
|
||||
assertDialectIsSupported(node);
|
||||
}
|
||||
node.forEach(pending::push);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 schema 객체의 참조 keyword가 같은 문서 안을 가리키는지 확인합니다. 문자열이 아닌 값은 참조가 아니라 {@code properties} 아래의 필드 정의이므로 건너뜁니다.
|
||||
*/
|
||||
private static void assertReferencesStayInDocument(JsonNode node) {
|
||||
for (String keyword : REFERENCE_KEYWORDS) {
|
||||
JsonNode reference = node.get(keyword);
|
||||
if (reference != null && reference.isTextual() && !reference.asText().startsWith("#")) {
|
||||
throw new IllegalStateException(
|
||||
"Tool inputSchema " + keyword + " must stay inside the document");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* schema가 선언한 dialect가 MCP가 쓰는 2020-12인지 확인합니다. 선언이 없으면 검증기의 기본 dialect가 적용되므로 통과시킵니다.
|
||||
*/
|
||||
private static void assertDialectIsSupported(JsonNode node) {
|
||||
JsonNode dialect = node.get("$schema");
|
||||
if (dialect != null && dialect.isTextual() && !SUPPORTED_DIALECT.equals(dialect.asText())) {
|
||||
throw new IllegalStateException("Tool inputSchema $schema must be " + SUPPORTED_DIALECT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package io.shinhanlife.dat.biz.mcp.registry;
|
||||
|
||||
import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 매니페스트가 준 정규식이 요청 스레드를 오래 붙잡지 못한다는 불변식을 고정하는 테스트입니다. 여기 실린 위험 정규식은 모두 JDK 21에서 실제로 되돌아오지 않는 것이 확인된 형태이고, 정상 업무 정규식이 함께 막히지
|
||||
* 않는지도 같이 검사합니다. 검사는 {@link ToolMetadata} 생성 시점에 걸리므로 Portal·local·Redis 중 어느 경로로 들어와도 같은 규칙이 적용됩니다.
|
||||
*/
|
||||
class ToolSchemaPatternPolicyTest {
|
||||
|
||||
@Test
|
||||
void rejectsRepeatingAGroupThatAlreadyRepeatsWithoutBound() {
|
||||
// (x+x+)+y 는 입력 1000자에서 되돌아오지 않았다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"q":{"type":"string","maxLength":64,"pattern":"(x+x+)+y"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("repeats without bound");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsABoundedRepetitionOfAnUnboundedGroup() {
|
||||
// (.*,){11}P 는 바깥 반복이 11회로 묶여 있어도 입력 1000자에서 되돌아오지 않았다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"q":{"type":"string","maxLength":64,"pattern":"(.*,){11}P"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("repeats without bound");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMoreUnboundedQuantifiersThanTheBudget() {
|
||||
// a*a*a*a*a*b 는 입력 100자에서 이미 되돌아오지 않았다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"q":{"type":"string","maxLength":64,"pattern":"a*a*a*a*a*b"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("unbounded quantifiers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAPatternedFieldWithoutALengthBound() {
|
||||
// 모양 검사만으로는 겹치는 문자 집합을 가려낼 수 없어, 길이 상한이 실질적인 방어선이다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"q":{"type":"string","pattern":"^[0-9]+$"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("requires maxLength");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsALengthBoundLargeEnoughToStillHurt() {
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"q":{"type":"string","maxLength":100000,"pattern":"^[0-9]+$"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("256");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPatternPropertiesOutright() {
|
||||
// 입력 객체의 key가 대상이라 길이를 묶을 자리가 없다. 안 쓰는 keyword라 통째로 닫는다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","patternProperties":{"^[a-z]+$":{"type":"string"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("patternProperties");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPatternPropertiesEvenWhenPropertyNamesLooksBounded() {
|
||||
// propertyNames로 길이를 묶어도 keyword 평가 순서가 명세에 없어 정규식이 먼저 돌 수 있다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","propertyNames":{"maxLength":16},
|
||||
"patternProperties":{"^(a+a+)+$":{"type":"string"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("patternProperties");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPatternPropertiesNestedBelowTheRoot() {
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"filter":{"type":"object",
|
||||
"patternProperties":{"^k":{"type":"string"}}}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("patternProperties");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAPatternThatDoesNotCompile() {
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"q":{"maxLength":64,"pattern":"([unclosed"}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("valid regular expression");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnExcessivelyLongPattern() throws Exception {
|
||||
JsonNode schema = OBJECT_MAPPER.readTree(
|
||||
"{\"type\":\"object\",\"properties\":{\"q\":{\"maxLength\":64,\"pattern\":\""
|
||||
+ "a".repeat(513) + "\"}}}");
|
||||
|
||||
assertThatThrownBy(() -> metadata(schema))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("512");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsOrdinaryBusinessPatterns() {
|
||||
// 실제 업무 schema가 이 정책 때문에 막히면 안 된다.
|
||||
assertThatCode(() -> metadata("""
|
||||
{"type":"object","properties":{
|
||||
"customerNo":{"type":"string","maxLength":10,"pattern":"^[0-9]{10}$"},
|
||||
"email":{"type":"string","maxLength":128,"pattern":"^[^@ ]+@[^@ ]+$"},
|
||||
"code":{"type":"string","maxLength":64,"pattern":"^([A-Z]{3}-)+[0-9]+$"}}}
|
||||
"""))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsQuantifierCharactersInsideACharacterClassLiteral() {
|
||||
// "[+*]"의 +와 *는 수량자가 아니라 문자다. 오탐으로 정상 Tool을 막으면 안 된다.
|
||||
assertThatCode(() -> metadata("""
|
||||
{"type":"object","properties":{"op":{"type":"string","maxLength":32,"pattern":"^[+*]+$"}}}
|
||||
"""))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesFieldsWithoutAPatternAlone() {
|
||||
// pattern이 없으면 maxLength를 요구하지 않는다.
|
||||
assertThatCode(() -> metadata("""
|
||||
{"type":"object","properties":{"memo":{"type":"string"}}}
|
||||
"""))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAToolWithoutAnyInputSchema() {
|
||||
assertThatCode(() -> metadata((JsonNode) null)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열을 schema로 갖는 {@link ToolMetadata}를 만들어 생성 시점 검사를 태웁니다.
|
||||
*/
|
||||
private static ToolMetadata metadata(String schemaJson) throws JsonProcessingException {
|
||||
return metadata(OBJECT_MAPPER.readTree(schemaJson));
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 대상 schema 외의 필드는 실행에 영향을 주지 않는 고정값으로 채웁니다.
|
||||
*/
|
||||
private static ToolMetadata metadata(JsonNode schema) {
|
||||
return new ToolMetadata(
|
||||
"a.search", "1.0.0", "search", "http://tool-a/mcp", schema, 1_000, true, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package io.shinhanlife.dat.biz.mcp.registry;
|
||||
|
||||
import static io.shinhanlife.dat.biz.mcp.TestFixtures.OBJECT_MAPPER;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 매니페스트가 준 {@code inputSchema}로는 서버의 outbound 호출 대상을 정할 수 없다는 불변식을 고정하는 테스트입니다. 검사는 {@link ToolMetadata} 생성 시점에 걸리므로 Portal·local·Redis 중 어느
|
||||
* 경로로 들어와도 같은 규칙이 적용되는지를 값 객체 수준에서 확인합니다.
|
||||
*/
|
||||
class ToolSchemaReferencePolicyTest {
|
||||
|
||||
@Test
|
||||
void rejectsASchemaThatReferencesAnAbsoluteUrl() {
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","$ref":"http://attacker.example/schema.json"}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("$ref");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAReferenceHiddenDeepInsideNestedProperties() {
|
||||
// 최상위만 보는 검사로는 막히지 않는 위치다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","properties":{"outer":{"type":"object",
|
||||
"properties":{"inner":{"$ref":"https://attacker.example/deep.json"}}}}}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAReferenceInsideAnArrayKeyword() {
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","allOf":[{"$ref":"//attacker.example/protocol-relative.json"}]}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsADynamicReferenceThatLeavesTheDocument() {
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"type":"object","$dynamicRef":"https://attacker.example/dynamic.json#node"}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("$dynamicRef");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsADialectOtherThanDraft202012() {
|
||||
// 낯선 dialect를 선언하면 검증기가 그 meta-schema를 외부에서 받아오려 할 수 있다.
|
||||
assertThatThrownBy(() -> metadata("""
|
||||
{"$schema":"https://attacker.example/meta.json","type":"object"}
|
||||
"""))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("$schema");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAReferenceThatStaysInsideTheDocument() throws Exception {
|
||||
JsonNode schema = OBJECT_MAPPER.readTree("""
|
||||
{"type":"object","properties":{"customer":{"$ref":"#/$defs/customer"}},
|
||||
"$defs":{"customer":{"type":"string"}}}
|
||||
""");
|
||||
|
||||
assertThatCode(() -> metadata(schema)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTheDeclaredDraft202012Dialect() {
|
||||
assertThatCode(() -> metadata("""
|
||||
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}
|
||||
"""))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsAToolWithoutAnyInputSchema() {
|
||||
assertThatCode(() -> metadata((JsonNode) null)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAPropertyLiterallyNamedRefUsable() throws Exception {
|
||||
// "$ref"라는 이름의 필드 정의는 참조가 아니라 일반 property이므로 막히면 안 된다.
|
||||
JsonNode schema = OBJECT_MAPPER.readTree("""
|
||||
{"type":"object","properties":{"$ref":{"type":"string"}}}
|
||||
""");
|
||||
|
||||
assertThat(metadata(schema).inputSchema().path("properties").has("$ref")).isTrue();
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열을 schema로 갖는 {@link ToolMetadata}를 만들어 생성 시점 검사를 태웁니다.
|
||||
*/
|
||||
private static ToolMetadata metadata(String schemaJson) throws JsonProcessingException {
|
||||
return metadata(OBJECT_MAPPER.readTree(schemaJson));
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 대상 schema 외의 필드는 실행에 영향을 주지 않는 고정값으로 채웁니다.
|
||||
*/
|
||||
private static ToolMetadata metadata(JsonNode schema) {
|
||||
return new ToolMetadata(
|
||||
"a.search", "1.0.0", "search", "http://tool-a/mcp", schema, 1_000, true, null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user