Compare commits
8 Commits
main
...
feature/mc
| Author | SHA1 | Date | |
|---|---|---|---|
| 6127ffb6ce | |||
| 973c650bed | |||
| dc6f60219b | |||
| 6746c59ab3 | |||
| e8ed554351 | |||
| 5d8d004f93 | |||
| 4e4c341f5d | |||
| ad7fccbed1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,6 +21,10 @@ AGENTS.md
|
||||
# 결정은 docs/decisions/의 ADR에, 규칙은 계약 테스트에 남긴다(AGENTS.md 4절).
|
||||
docs/superpowers/
|
||||
|
||||
# 에이전트 도구가 로컬에 만드는 상태 파일. 개발자마다 달라지므로 공유하지 않는다.
|
||||
.ua/
|
||||
skills-lock.json
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -21,7 +21,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
|
||||
## 전체 실행 흐름
|
||||
|
||||
1. Agent Builder가 배포별 공개 URL `https://{host}{publicPath}`을 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). 요청 body·header·Tool 이름은 이 선택에 관여하지 않는다.
|
||||
1. Agent Builder가 route별 공개 URL `https://{host}/mcp/{routeKey}`를 호출한다. OpenShift Route는 host와 path로 MCP Service만 선택하고 원래 path를 컨테이너에 전달한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). route key는 이 URI에서만 결정하며 설정 기본값으로 보정하지 않는다([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). Portal 모드에서 route가 없는 `/mcp` 호출은 `route key is required`로 거부한다. 요청 body·header·Tool 이름은 route 선택에 관여하지 않는다.
|
||||
2. 컨테이너의 `mcp.endpoint-path` 전용 `McpExchangeFilter`가 header를 검증/추출하고 요청 전체 deadline을 포함한 `McpRequestContext`를 만든다. 호출자 헤더 다섯 개(`guid`, `x-request-id`, `mcp-session-id`, `employee-no`, `virtual-employee-no`)는 모두 선택값이며, 응답과 downstream Tool 호출에 그대로 전파한다. `guid`는 요청 하나의 end-to-end 상관 값, `x-request-id`는 개별 HTTP 요청 식별자다.
|
||||
3. filter는 크기가 제한된 repeatable request body에서 `method`만 관찰용으로 읽고 `mcp_http_request_received` 로그를 남긴다. body, header 값, credential은 로그에 저장하지 않는다.
|
||||
4. `McpProtocolVersionValidator`가 `initialize`를 제외한 요청의 `MCP-Protocol-Version`을 supported versions와 대조한다. 누락·불일치는 Controller 진입 전 HTTP 400으로 종료한다.
|
||||
@@ -30,7 +30,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
6. `McpMethodHandlerRegistry`가 method를 명시적 handler에 연결한다.
|
||||
7. `tools/list`는 `ToolRegistryService`의 in-memory snapshot에서 실행 metadata를 얻는다. 요청 경로는 Redis를 호출하지 않으므로 Redis 장애·지연이 응답에 영향을 주지 않으며, snapshot이 비어 있는 기동 직후에만 Tool catalog provider를 한 번 조회한다. 이후 `ToolsListHandler`가 MCP SDK의 `Tool`과 `ListToolsResult`로 변환한다. local 기본 구성은 Tool Service 매니페스트를 먼저 조회하고, 최초 실패 시 bundle별 local manifest sample을 cold-start fallback으로 사용한다. 운영은 이 배포가 보는 Tool Service 매니페스트의 사용 가능한 성공본만 원천으로 사용한다.
|
||||
8. `tools/call`은 `ToolsCallHandler`가 표준 MCP의 `params.name`과 object인 `params.arguments`를 검증하고 추출한다.
|
||||
9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다.
|
||||
9. `ToolExecutionService`가 표준 Tool name으로 metadata를 확정하고 argument schema를 검증한다. `inputSchema` 자체의 안전성은 실행 시점이 아니라 `ToolMetadata` 생성 시점에 이미 검사됐다. 매니페스트 파싱·local 파일 로딩·Redis snapshot 역직렬화가 모두 같은 생성자를 지나므로 검사 지점은 하나다. `ToolRoutingService`는 snapshot에 저장된 정확한 Tool endpoint와 metadata timeout으로 HTTP 요청을 만든다. Agent Builder가 보낸 `arguments` 객체는 JSON raw body로 전달하며 MCP가 Tool을 대체 선택하지 않는다.
|
||||
10. `arguments`의 어떤 field도 outbound URL 선택에 사용하지 않는다. Portal registry는 Tool Server의 `serviceDomain`과 `manifestPath`만 제공하고, Tool별 실행 endpoint는 Tool Server manifest의 top-level `endpoint` 또는 `_meta.endpoint`에서 가져온다. manifest endpoint가 절대 HTTP(S) URL이면 Tool Server가 제공한 실행 주소 원천으로 허용하고, 상대 경로이면 Portal registry의 `serviceDomain` 뒤에 붙인다.
|
||||
11. `HttpToolClient`가 JDK 공유 HTTP client의 connection pool을 사용해 correlation 헤더와 함께 POST를 실행한다. arguments는 JSON body로 전달하며 Tool read timeout은 metadata timeout과 요청 전체 deadline의 남은 시간 이하로 제한한다. Authorization 전달은 설정으로 통제한다.
|
||||
12. Tool 응답은 요청 payload와 분리해 `response.data`만 사용한다. plain text는 그대로, JSON object/array는 compact JSON string으로 MCP SDK `CallToolResult`/`TextContent`의 `result.content[0].text`에 넣고 outer JSON serializer가 escaping을 처리한다. 호출 소요 시간(ms)은 `result.content[0]._meta.searchTime`으로 반환하고, 정상 결과에도 `isError: false`를 명시한다. Tool 실행·timeout·권한 오류는 JSON-RPC error가 아니라 `isError: true` result로 변환한다. JSON-RPC envelope/params/method 및 서버 구성 오류는 최상위 JSON-RPC `error`로 반환한다.
|
||||
@@ -44,6 +44,7 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
|---|---|---|
|
||||
| `McpController` | `transport/http` | `mcp.endpoint-path`의 단일 공개 endpoint, parser/handler 연결, notification 202와 initialize UUID header 선택 |
|
||||
| `McpRequestContextFactory` | `transport/http` | 호출자 헤더 5종 추출. correlation 값 형식 검증, 사원 식별자는 해석하지 않고 주입 위험 문자만 차단 |
|
||||
| `McpRouteKeyValidator` | `transport/http` | `/mcp/{routeKey}`의 route가 현재 서버가 아는 route인지 확인하는 transport 전용 port. memory snapshot만 읽고 registry를 직접 참조하지 않아 패키지 경계를 유지 |
|
||||
| `McpRequestContextHolder` | `context` | 요청 수명 ThreadLocal 저장; 세션 저장소가 아님 |
|
||||
| `JsonRpcRequestParser` | `jsonrpc` | JSON-RPC envelope shape 검증과 내부 request 정규화 |
|
||||
| `McpMethodHandlerRegistry` | `method` | `Handler` 전략과 method dispatch를 한 경계에서 관리 |
|
||||
@@ -52,8 +53,12 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
| `LocalFileToolRegistryClient` | `registry` | 매니페스트 조회를 끈 local profile에서 legacy JSON/manifest fixture를 읽어 테스트 Tool 목록을 제공 |
|
||||
| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 |
|
||||
| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 |
|
||||
| `PortalToolRegistryClient` | `registry` | Portal registry를 route → Tool Service 목록(`bundlesByRoute`)으로 유지하고 route별 manifest를 조회. 한 route의 실패는 `fetchRouteToolsSafely()`로 격리해 다른 route 수집을 막지 않음 |
|
||||
| `RedisPortalRegistryCache` | `registry` | Portal Registry API 장애 시 endpoint registry JSON을 읽는 선택적 Redis fallback. route별 Tool snapshot key와 분리 |
|
||||
| `ToolSchemaReferencePolicy` | `registry` | `inputSchema`가 문서 밖을 가리키는 `$ref`·`$dynamicRef`와 미지원 dialect를 거부([ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)) |
|
||||
| `ToolSchemaPatternPolicy` | `registry` | `pattern` 정규식의 길이·무한 수량자·중첩 반복을 제한하고 `maxLength` 동반을 요구. `patternProperties`는 거부([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)) |
|
||||
| `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 |
|
||||
| `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 시 애플리케이션 생존 |
|
||||
| `ToolRegistryRefreshScheduler` | `registry` | `ApplicationReadyEvent`에서 warm start와 원천 preload를 한 번 실행. 주기 실행은 하지 않으며 이름과 달리 scheduler가 아니다. 실패해도 애플리케이션은 생존 |
|
||||
| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 |
|
||||
| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping |
|
||||
| `ToolRoutingService` | `execute` | 단일 POST endpoint와 timeout 확정, 기본 URI 검증 |
|
||||
@@ -65,6 +70,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
|
||||
| `McpProtocolVersionValidator` | `transport/http` | `initialize` 이후 HTTP `MCP-Protocol-Version`의 지원 여부 검증; 서버 상태를 저장하지 않음 |
|
||||
| `TraceLogger` | `observability` | context의 guid/requestId를 직접 포함하는 최소 key=value 경계 로그. 사원 식별자는 기록하지 않음 |
|
||||
| `McpExceptionHandler` | `transport/http` | JSON parse, JSON-RPC, 예상 밖 오류의 표준 response 변환 |
|
||||
| `AgentRoutingHintsProperties` | `config` | initialize 응답 `_meta`에 넣을 Tool Server routing manifest 조회 경로 설정(기본 `/tool-service-manifest`) |
|
||||
| `LocalFixtureProperties` | `config` | 실제 Tool Server가 없는 local·dev 환경의 임시 manifest·응답 파일 위치. 기본 비활성이며 실서버 확보 후 제거 대상 |
|
||||
|
||||
Spring Boot 3.5가 관리하는 Jackson 2 databind 모델과 annotation API는 `com.fasterxml.jackson.*` namespace를 사용한다. Registry 응답의 unknown field 무시는 회귀 테스트로 검증한다.
|
||||
|
||||
@@ -145,6 +152,18 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
|
||||
중복 실행 방지는 Tool Service의 책임이다. retry에서 같은 `guid`를 재사용해 멱등성 키로 삼을지는
|
||||
[미합의 항목](extension-points.md)이며, 합의 전에는 MCP가 이를 보장한다고 가정하지 않는다.
|
||||
|
||||
## Tool 호출 retry
|
||||
|
||||
MCP는 Tool 호출 실패를 제한적으로 재시도한다. 세 조건이 모두 참일 때만 재시도하며, 하나라도 거짓이면 단발 호출이다(`ToolRoutingService:64`).
|
||||
|
||||
1. `mcp.tool-client.retry.enabled`가 참이고 `max-attempts`가 2 이상이다(기본 `true`, `2`).
|
||||
2. HTTP status가 `retry-on-http-status` 목록에 있다(기본 408, 429).
|
||||
3. Tool이 annotations로 안전하다고 선언했다.
|
||||
|
||||
3번은 `ToolMetadata.retrySafeByAnnotation()`이 공개 정의의 annotations로 판단한다. `destructiveHint`가 참이면 **항상 금지**하고, 그렇지 않은 경우에만 `readOnlyHint` 또는 `idempotentHint`가 참이면 허용한다. annotations가 없으면 허용하지 않는다. 즉 **선언하지 않은 Tool은 재시도하지 않는다.**
|
||||
|
||||
재시도는 같은 요청 deadline 안에서 일어나므로 `remainingMillis()` 예산을 넘지 못한다. 멱등성 자체는 Tool Service의 책임이며, MCP는 Tool이 선언한 annotations를 신뢰할 뿐 검증하지 않는다([ADR-0004](decisions/ADR-0004-execution-guardrails.md)).
|
||||
|
||||
## Protocol version 협상과 검증
|
||||
|
||||
- 서버는 `mcp.protocol.supported-versions`와 `mcp.protocol.preferred-version`으로 지원 버전을 명시적으로 관리한다. preferred version은 반드시 supported versions에 포함되어야 한다.
|
||||
@@ -159,6 +178,12 @@ Tool 호출 직전마다 `remainingMillis()`로 남은 예산을 계산해 read
|
||||
- Agent Builder는 MCP 2025-11-25 lifecycle에 따라 `notifications/initialized`를 보낸다. 서버는 이를 저장하거나 이후 요청의 readiness gate로 사용하지 않는다.
|
||||
- `InitializedNotificationHandler`는 id 없는 notification을 HTTP 202으로 수용한다. 이는 Tool 실행 준비 상태를 메모리에 세우는 동작이 아니므로 replica 간 affinity가 필요 없다.
|
||||
|
||||
## Agent routing hint
|
||||
|
||||
`mcp.agent-routing-hints.enabled`가 켜져 있고 요청에 route가 있으면, `initialize` 응답의 `_meta`에 `toolServers` 배열을 실어 보낸다. `InitializeHandler`가 해당 route의 Tool Server에서 `mcp.agent-routing-hints.manifest-path`(기본 `/tool-service-manifest`)를 조회해 받은 JSON을 **변환 없이 그대로** 감싼다.
|
||||
|
||||
기능이 꺼져 있거나 route가 없으면 `_meta`를 붙이지 않고 표준 `initialize` 응답만 반환한다. 이 값은 Agent Builder에 주는 힌트이며 MCP의 Tool 실행 경로는 이를 읽지 않는다.
|
||||
|
||||
## Tool metadata 갱신 장애 시나리오
|
||||
|
||||
요청 경로는 memory만 읽으므로 Redis 상태가 등장하지 않는다.
|
||||
@@ -198,7 +223,7 @@ Redis는 요청 경로의 의존성이 아닌 선택적인 warm-start cache다.
|
||||
|
||||
로컬 검증에서는 `mcp.portal.registry-url`을 `file:./config/local-toolserver-info-sample-v1.json` 같은 Spring resource location으로 지정할 수 있다. 이 경우 MCP는 기동 preload와 주기 endpoint refresh에서 Portal HTTP API를 호출하지 않고 프로젝트 안의 registry JSON을 읽는다. 파일에서 확보한 endpoint 목록 이후의 Tool Server `tool-manifest` 주기 조회, route별 in-memory snapshot 갱신, Redis fallback 규칙은 Portal API를 사용할 때와 동일하다.
|
||||
|
||||
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain`과 `manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후에는 `mcp.registry.refresh-interval-seconds` 주기로 저장된 Tool Server 목록에 대해 manifest만 다시 조회하고, `mcp.portal.refresh-interval-seconds` 주기로 포털 registry만 별도로 갱신한다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
|
||||
Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server 목록의 원천으로만 사용한다. MCP는 기동 preload 때 포털 registry API를 먼저 호출해 `serviceDomain`과 `manifestPath`를 확보한 뒤 Tool Server `tool-manifest`를 조회한다. 이후 갱신은 주기 실행이 아니라 **요청 시점 TTL 만료**로 일어난다. 요청이 들어오면 `ToolRegistryService`가 `mcp.portal.refresh-ttl-seconds`가 지났을 때만 포털 registry를, `mcp.registry.refresh-ttl-seconds`가 지났을 때만 해당 route의 manifest를 다시 조회한다. 아직 snapshot이 없는 route는 TTL과 무관하게 조회하며, 포털 endpoint 목록 변경이 그 route의 마지막 manifest 조회보다 나중이면 TTL이 남아 있어도 manifest를 다시 읽는다. 요청이 없으면 갱신도 일어나지 않는다. 포털 `registryRevision`은 포털 응답 JSON 변경 로그와 Tool Server 목록 변경 진단에 사용하며, Tool Server 내부 tool/schema/revision/endpoint 변경 감지는 MCP의 manifest 주기 조회 결과를 route별 in-memory snapshot에 다시 병합하면서 처리한다. 요청 경로의 `tools/list`와 `tools/call`은 계속 in-memory snapshot만 읽는다. Portal API 조회가 실패하면 이미 확보한 in-memory Tool Server snapshot을 유지하며, cold start처럼 memory가 비어 있을 때만 `mcp.redis.portal-registry-key`의 Redis registry JSON을 fallback으로 읽는다. 이 Portal registry fallback은 route 목록과 Tool Server 목록 확보용이고, route별 Tool snapshot Redis key는 이미 알고 있는 route의 마지막 Tool 목록 fallback에만 사용한다. Redis fallback도 실패하면 Tool Server 원천을 확보하지 못한 것으로 처리하고 다음 주기에서 재시도한다.
|
||||
|
||||
노출 대상 Tool은 그 파일이 정의한다. 목록을 이 문서에 옮겨 적지 않는다. 파일의 공개 필드는 그대로 보존하고 `_meta`와 `endpoint` 실행 정보만 제거해 `tools/list`에 내보낸다. fallback도 원격 매니페스트와 같이 top-level `endpoint` 또는 `_meta.endpoint`를 내부 실행 endpoint로 사용한다.
|
||||
|
||||
@@ -206,9 +231,15 @@ Portal Registry를 사용하는 구성에서는 포털을 route별 Tool Server
|
||||
|
||||
운영 profile에서는 `ToolBundleDiscovery`와 `ToolBundleRegistryClient`만 metadata 원천으로 활성화한다. MCP 배포별 `mcp.bundles`가 Tool Service의 매니페스트와 실행 주소를 선언한다. 운영 Helm 설정에는 fallback 파일을 넣지 않는다. Tool Service는 표준 `name`을 소유하고, MCP는 자기 Bundle 안에서 형식·설정된 `namePrefix`·중복을 검증하되 이름을 재작성하지 않는다. 서로 다른 MCP 배포 간 이름의 전역 유일성은 Tool Service·플랫폼의 변경 절차가 보장한다. Redis는 선택적인 공유 last-good cache일 뿐 Tool 목록의 원천이 아니다.
|
||||
|
||||
**`mcp.bundles`는 N개를 지원하지만 운영 배포에서는 항상 한 항목이다.** MCP 배포 하나가 Tool Service 하나만 보기로 했기 때문이다([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 이 목록을 늘리는 것이 아니라 MCP 배포를 하나 더 만드는 것이다. 그래야 등급이 다른 Tool Service의 조회 실패가 서로의 카탈로그 갱신을 막지 않는다. 다중 bundle 병합 코드는 유지하되 Helm Chart가 1개로 잠그고 `HelmDeploymentContractTest`가 그 사실을 검사한다.
|
||||
**MCP 배포 하나가 N개 route를 서비스하고, route 하나에 N개 Tool Service가 붙는다**([ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). 카탈로그 병합 단위는 route다. 이전의 1:1 전제([ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md), `Superseded`)는 매핑이 배포 시점에 확정된다는 가정 위에 있었으나, 매핑의 원천이 Portal로 옮겨지면서 성립하지 않는다.
|
||||
|
||||
각 배포는 같은 환경 host의 고유 `publicPath`를 가진 OpenShift Route로 노출된다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)). Route는 path로 Service만 선택하고 컨테이너가 같은 값을 `mcp.endpoint-path`로 직접 처리한다. Java 애플리케이션에는 route table이나 다중 Registry를 추가하지 않는다. Deployment·snapshot·readiness·connection pool은 path별로 분리되고, 공유되는 장애 지점은 OpenShift ingress와 DNS다.
|
||||
등급별 물리 격리는 이 구조에서 얻지 못한다. 무엇이 남는지는 ADR-0013의 격리 표가 정본이며, 요약하면 route별 snapshot 보관과 route 간 갱신은 격리되지만 프로세스 자원·배포·재기동은 전 route가 공유한다.
|
||||
|
||||
`mcp.bundles` 기반 1:1 구성은 코드에 남아 있어 local 검증과 1:1 배포에서 유효하고 `HelmDeploymentContractTest`가 그 계약을 검사한다. 내부망 운영 대상인지는 ADR-0013이 정하지 않는다.
|
||||
|
||||
Route는 path로 Service만 선택하고 컨테이너가 `mcp.endpoint-path`와 그 아래 `{routeKey}`를 직접 처리한다([ADR-0009](decisions/ADR-0009-container-handles-public-mcp-path.md)의 결정 4는 ADR-0013이 대체한다). `McpController`는 `${mcp.endpoint-path}`와 `${mcp.endpoint-path}/{routeKey}` 두 패턴을 받는다.
|
||||
|
||||
route 매핑은 애플리케이션 안에 있다. `PortalToolRegistryClient`가 Portal registry를 route → Tool Service 목록으로 유지하고, `ToolRegistryService`가 route별 snapshot을 들고, `McpRouteKeyValidator`가 등록되지 않은 route를 controller 진입 전에 거부한다. snapshot과 Redis key는 route별로 분리되지만 Deployment·readiness·connection pool은 전 route가 공유하며, 공유되는 장애 지점은 프로세스 자체와 OpenShift ingress·DNS다.
|
||||
|
||||
운영 상태는 외부 ingress가 아니라 management port(기본 9090)의 `GET /actuator/toolBundles`로 확인한다.
|
||||
|
||||
|
||||
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,87 @@
|
||||
# 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가 비어 있는지 관제가 알 수 없다.
|
||||
|
||||
## 남은 판단
|
||||
|
||||
- `McpProperties.Portal`에 `routeKey` 컴포넌트가 선언돼 있으나 어떤 코드도 읽지 않는다. `application.yml`에 `route-key` 키도 없고, `.portal()` 호출 다섯 곳 중 `routeKey()`를 읽는 곳이 없다. 결정 2에 따라 route는 URI에서만 오므로 이 컴포넌트는 제거 대상이다. 설정으로 기본 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,74 @@
|
||||
package io.shinhanlife.dat.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* {@code docs/architecture.md}의 클래스 책임 표가 실제 소스와 어긋나지 않는지 확인하는 문서 계약 테스트입니다. 이 표는 코드 구조를 문서에 복제한 것이라 class를 rename하거나 package를 옮기면 조용히 낡습니다. 실제로 패키지 재구성 한 번에 네
|
||||
* 개의 이름이 죽은 적이 있어, 사람의 주의력 대신 테스트로 고정합니다. 소스를 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class ArchitectureDocumentContractTest {
|
||||
|
||||
private static final Path ARCHITECTURE = Path.of("docs", "architecture.md");
|
||||
private static final Path MAIN_PACKAGE =
|
||||
Path.of("src", "main", "java", "io", "shinhanlife", "dat", "biz", "mcp");
|
||||
/**
|
||||
* 표의 첫 두 칸에 백틱으로 감싼 타입 이름과 패키지 경로가 있는 행만 뽑는다.
|
||||
*/
|
||||
private static final Pattern TABLE_ROW =
|
||||
Pattern.compile("^\\| `([A-Z][A-Za-z0-9]*)` \\| `([a-z0-9/]+)` \\|");
|
||||
|
||||
/**
|
||||
* 클래스 표에 적힌 모든 타입이 {@code src/main/java}에 실제로 존재하는지 확인합니다. 존재하지 않는 이름이 있으면 rename 후 문서를 갱신하지 않은 것이므로, 어떤 이름인지 함께 알려 줍니다.
|
||||
*/
|
||||
@Test
|
||||
void everyDocumentedClassPathStillExists() throws IOException {
|
||||
List<DocumentedType> documented = documentedTypes();
|
||||
|
||||
// 표 자체가 사라지면 이 테스트가 조용히 통과해 버리므로 최소 개수를 함께 고정한다.
|
||||
assertThat(documented)
|
||||
.withFailMessage("architecture.md의 클래스 책임 표를 찾지 못했습니다. 표 형식이 바뀌었는지 확인하세요.")
|
||||
.hasSizeGreaterThan(10);
|
||||
|
||||
List<DocumentedType> missing = documented.stream().filter(type -> !sourceExists(type)).toList();
|
||||
|
||||
assertThat(missing)
|
||||
.withFailMessage(
|
||||
"architecture.md에 적힌 package와 class 경로에 소스가 없는 타입: %s%n"
|
||||
+ "class를 rename하거나 package를 옮겼다면 문서의 표도 같은 변경에서 고쳐야 합니다.",
|
||||
missing)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 클래스 책임 표에서 타입 이름과 패키지 경로를 순서대로 모읍니다.
|
||||
*/
|
||||
private List<DocumentedType> documentedTypes() throws IOException {
|
||||
try (Stream<String> lines = Files.lines(ARCHITECTURE)) {
|
||||
return lines.map(TABLE_ROW::matcher)
|
||||
.filter(Matcher::find)
|
||||
.map(matcher -> new DocumentedType(matcher.group(1), matcher.group(2)))
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서에 적힌 패키지와 타입 이름이 가리키는 main 소스 파일이 정확히 존재하는지 확인합니다.
|
||||
*/
|
||||
private boolean sourceExists(DocumentedType type) {
|
||||
return Files.isRegularFile(MAIN_PACKAGE.resolve(type.packagePath()).resolve(type.name() + ".java"));
|
||||
}
|
||||
|
||||
private record DocumentedType(String name, String packagePath) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package io.shinhanlife.dat.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Java 소스의 기계적 서식 규칙을 빌드에서 강제하는 계약 테스트입니다. 이전에는 Spotless Gradle 플러그인이 같은 검사를 했지만, 그 플러그인은 빌드를 읽는 시점에 외부 저장소에서 내려받아야 해서 폐쇄망에서는 검사 하나 때문에 빌드 전체가 시작되지 못합니다. 규칙을
|
||||
* 여기로 옮겨 외부 의존성 없이 같은 것을 지킵니다.
|
||||
*
|
||||
* <p>여기서 보는 것은 <b>도구 없이도 판정할 수 있는 규칙</b>뿐입니다. 들여쓰기 폭과 줄바꿈 위치는 IntelliJ 코드 스타일({@code .idea/codeStyles/Project.xml})이 소유하며 이 테스트가 판정하지 않습니다. 소스를 읽기만 하며
|
||||
* 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class CodeStyleContractTest {
|
||||
|
||||
private static final List<Path> SOURCE_ROOTS =
|
||||
List.of(Path.of("src", "main", "java"), Path.of("src", "test", "java"));
|
||||
/**
|
||||
* {@code import a.b.C;}와 {@code import static a.b.C.d;}에서 마지막 이름만 뽑는다.
|
||||
*/
|
||||
private static final Pattern IMPORT = Pattern.compile("^import (?:static )?[\\w.]*?(\\w+);");
|
||||
|
||||
/**
|
||||
* 모든 Java 소스가 LF 줄바꿈만 쓰는지 확인합니다. CRLF가 섞이면 Linux 컨테이너에서 문제가 되고, 한 번 섞인 파일은 이후 모든 변경의 diff가 파일 전체로 부풀어 실제 변경을 가립니다.
|
||||
*/
|
||||
@Test
|
||||
void everySourceUsesUnixLineEndings() throws IOException {
|
||||
List<String> broken = violations(source -> source.raw().contains("\r\n"));
|
||||
|
||||
assertThat(broken).withFailMessage("CRLF 줄바꿈이 있는 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 들여쓰기에 탭을 쓰지 않는지 확인합니다. 탭과 공백이 섞이면 보는 도구마다 정렬이 달라집니다.
|
||||
*/
|
||||
@Test
|
||||
void noSourceContainsTabCharacters() throws IOException {
|
||||
List<String> broken = violations(source -> source.raw().contains("\t"));
|
||||
|
||||
assertThat(broken).withFailMessage("탭 문자가 있는 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄 끝에 눈에 보이지 않는 공백이 남아 있지 않은지 확인합니다. 화면에 드러나지 않아 사람이 리뷰로 잡을 수 없고, 의미 없는 diff만 만듭니다.
|
||||
*/
|
||||
@Test
|
||||
void noLineEndsWithWhitespace() throws IOException {
|
||||
List<String> broken =
|
||||
violations(
|
||||
source ->
|
||||
source.lines().stream()
|
||||
.anyMatch(line -> !line.equals(line.stripTrailing())));
|
||||
|
||||
assertThat(broken).withFailMessage("줄 끝에 공백이 있는 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 개행 하나로 끝나는지 확인합니다. 개행이 없으면 마지막 줄을 고칠 때 diff가 두 줄로 보이고, 여러 개면 의미 없는 빈 줄이 쌓입니다.
|
||||
*/
|
||||
@Test
|
||||
void everySourceEndsWithExactlyOneNewline() throws IOException {
|
||||
List<String> broken =
|
||||
violations(source -> !source.raw().endsWith("\n") || source.raw().endsWith("\n\n"));
|
||||
|
||||
assertThat(broken).withFailMessage("파일 끝 개행이 정확히 하나가 아닌 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰지 않는 {@code import}가 남아 있지 않은지 확인합니다. 클래스를 옮기거나 지운 뒤 정리하지 않으면 남으며, 실제로는 없는 의존 관계가 있는 것처럼 보이게 합니다.
|
||||
*
|
||||
* <p>판정은 그 이름이 import 문 바깥 어디에든 나타나는지로 합니다. Javadoc의 {@code @link}도 사용으로 봅니다. 실제로 쓰는 import를 지우라고 하는 오탐이 없어야 하기 때문입니다.
|
||||
*/
|
||||
@Test
|
||||
void noSourceKeepsAnUnusedImport() throws IOException {
|
||||
List<String> unused = new ArrayList<>();
|
||||
for (JavaSource source : sources()) {
|
||||
String body =
|
||||
String.join(
|
||||
"\n",
|
||||
source.lines().stream().filter(line -> !line.startsWith("import ")).toList());
|
||||
for (String line : source.lines()) {
|
||||
Matcher matcher = IMPORT.matcher(line);
|
||||
if (matcher.find() && !containsWord(body, matcher.group(1))) {
|
||||
unused.add(source.path() + " -> " + matcher.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(unused).withFailMessage("사용하지 않는 import: %s", unused).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code import}가 static 먼저, 그다음 알파벳 순으로 놓였는지 확인합니다. 순서가 제각각이면 같은 import를 두 사람이 다른 자리에 넣어 실제 변경과 무관한 diff가 생깁니다.
|
||||
*
|
||||
* <p>비교는 <b>세미콜론을 뗀 경로</b>로 합니다. {@code A;}와 {@code A.B;}를 문자열 그대로 비교하면 {@code ';'}(0x3B)가 {@code '.'}(0x2E)보다 커서 중첩 타입이 바깥 타입보다 앞서야 한다고 잘못
|
||||
* 판정합니다.
|
||||
*
|
||||
* <p>그룹 사이 빈 줄은 검사하지 않습니다. 저장소 전체를 세어 보면 빈 줄을 넣은 경계와 넣지 않은 경계가 섞여 있어 지킬 관례가 존재하지 않습니다. 없는 규칙을 만들어 기존 파일을 무더기로 고치는 것보다, 실재하는 규칙만
|
||||
* 잠그는 편이 낫습니다.
|
||||
*/
|
||||
@Test
|
||||
void importsAreOrderedStaticFirstThenAlphabetically() throws IOException {
|
||||
List<String> broken = new ArrayList<>();
|
||||
for (JavaSource source : sources()) {
|
||||
List<String> statics = new ArrayList<>();
|
||||
List<String> regular = new ArrayList<>();
|
||||
for (String line : source.lines()) {
|
||||
if (line.startsWith("import static ")) {
|
||||
statics.add(line.substring("import static ".length()).replace(";", ""));
|
||||
} else if (line.startsWith("import ")) {
|
||||
regular.add(line.substring("import ".length()).replace(";", ""));
|
||||
}
|
||||
}
|
||||
if (!isSorted(statics) || !isSorted(regular)) {
|
||||
broken.add(source.path());
|
||||
}
|
||||
if (!source.staticImportsComeFirst()) {
|
||||
broken.add(source.path() + " (static import가 일반 import 뒤에 있음)");
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(broken).withFailMessage("import 순서가 어긋난 파일: %s", broken).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 대상 소스가 실제로 수집되는지 확인합니다. 경로가 바뀌어 목록이 비면 위 검사들이 모두 조용히 통과하므로 최소 개수를 함께 고정합니다.
|
||||
*/
|
||||
@Test
|
||||
void theSourceSetIsActuallyScanned() throws IOException {
|
||||
assertThat(sources())
|
||||
.withFailMessage("Java 소스를 찾지 못했습니다. SOURCE_ROOTS 경로가 바뀌었는지 확인하세요.")
|
||||
.hasSizeGreaterThan(50);
|
||||
}
|
||||
|
||||
/**
|
||||
* 규칙을 어긴 파일 경로를 모읍니다. 어떤 파일인지 알려주지 않으면 고칠 수가 없습니다.
|
||||
*/
|
||||
private List<String> violations(Predicate<JavaSource> broken) throws IOException {
|
||||
return sources().stream().filter(broken).map(JavaSource::path).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록이 오름차순인지 확인합니다. 정렬본과 비교하면 어긋난 위치를 따로 추적하지 않아도 됩니다.
|
||||
*/
|
||||
private boolean isSorted(List<String> values) {
|
||||
return values.equals(values.stream().sorted().toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름이 식별자 경계에 맞게 등장하는지 확인합니다. {@code List}를 찾을 때 {@code ArrayList}가 걸리지 않아야 합니다.
|
||||
*/
|
||||
private boolean containsWord(String text, String word) {
|
||||
return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(text).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* main과 test의 모든 Java 소스를 읽어 옵니다.
|
||||
*/
|
||||
private List<JavaSource> sources() throws IOException {
|
||||
List<JavaSource> sources = new ArrayList<>();
|
||||
for (Path root : SOURCE_ROOTS) {
|
||||
try (Stream<Path> paths = Files.walk(root)) {
|
||||
for (Path path : paths.filter(path -> path.toString().endsWith(".java")).toList()) {
|
||||
sources.add(
|
||||
new JavaSource(
|
||||
path.toString().replace('\\', '/'),
|
||||
new String(Files.readAllBytes(path), StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 대상 소스 하나의 경로와 원본 내용입니다. 줄바꿈 검사 때문에 줄 단위가 아니라 원본 문자열을 그대로 들고 있어야 합니다.
|
||||
*/
|
||||
private record JavaSource(String path, String raw) {
|
||||
|
||||
/**
|
||||
* 줄 단위 검사를 위해 개행으로만 나눕니다. CR이 남아 있으면 줄 끝 공백 검사에서도 함께 드러납니다.
|
||||
*/
|
||||
List<String> lines() {
|
||||
return List.of(raw.split("\n", -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 static import가 첫 일반 import보다 앞에 있는지 확인합니다. 둘 중 한쪽이 없으면 판정할 것이 없으므로 참입니다.
|
||||
*/
|
||||
boolean staticImportsComeFirst() {
|
||||
List<String> lines = lines();
|
||||
int lastStatic = -1;
|
||||
int firstRegular = Integer.MAX_VALUE;
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
String line = lines.get(index);
|
||||
if (line.startsWith("import static ")) {
|
||||
lastStatic = index;
|
||||
} else if (line.startsWith("import ") && firstRegular == Integer.MAX_VALUE) {
|
||||
firstRegular = index;
|
||||
}
|
||||
}
|
||||
return lastStatic < firstRegular;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package io.shinhanlife.dat.biz.mcp.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 패키지 경계를 코드로 고정하는 계약 테스트입니다. MCP는 stdio 등 다른 transport를 가질 수 있는 프로토콜이므로, inbound Servlet 지식이 전송 경계 밖으로 새면 전송 방식이 응용 계층에 굳어져 나중에 떼어낼 수 없게 됩니다. 실제로 재구성 전에는 서블릿
|
||||
* 타입이 세 패키지에 흩어져 있었고, 문서만으로는 다시 새는 것을 막지 못합니다. 소스 파일을 읽기만 하며 애플리케이션 context를 띄우지 않습니다.
|
||||
*/
|
||||
class PackageBoundaryContractTest {
|
||||
|
||||
private static final Path MAIN_SOURCES = Path.of("src", "main", "java");
|
||||
/**
|
||||
* 전송 경계 안쪽. 이 아래에서만 서블릿 API를 다룰 수 있다.
|
||||
*/
|
||||
private static final String TRANSPORT_PACKAGE = "io/shinhanlife/dat/biz/mcp/transport/";
|
||||
|
||||
/**
|
||||
* 서블릿 API를 import하는 production 파일이 {@code transport} 패키지 안에만 있는지 확인합니다. 밖에서 발견되면 어떤 파일인지 함께 알려 주고, 옮기거나 서블릿 타입을 걷어내도록 유도합니다.
|
||||
*/
|
||||
@Test
|
||||
void servletApiStaysInsideTheTransportPackage() throws IOException {
|
||||
List<Path> leaks = sourcesImporting("jakarta.servlet").stream()
|
||||
.filter(path -> !normalize(path).contains(TRANSPORT_PACKAGE))
|
||||
.toList();
|
||||
|
||||
assertThat(leaks)
|
||||
.withFailMessage(
|
||||
"jakarta.servlet은 transport 패키지 안에서만 사용한다. 경계 밖에서 발견된 파일: %s%n"
|
||||
+ "HTTP 전용 코드라면 transport/http로 옮기고, 아니라면 서블릿 타입을 파라미터에서 제거하세요.",
|
||||
leaks)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전송 경계 안쪽 코드가 Tool 실행·Registry 내부로 직접 들어가지 않는지 확인합니다. transport는 요청을 받아 method handler에 넘기는 데까지가 책임이며, 실행 상세는 그 뒤 계층이 소유합니다.
|
||||
*/
|
||||
@Test
|
||||
void transportDoesNotReachIntoExecutionOrRegistry() throws IOException {
|
||||
List<Path> violations = sourcesImportingAny(List.of(
|
||||
"io.shinhanlife.dat.biz.mcp.execute.",
|
||||
"io.shinhanlife.dat.biz.mcp.registry."))
|
||||
.stream()
|
||||
.filter(path -> normalize(path).contains(TRANSPORT_PACKAGE))
|
||||
.toList();
|
||||
|
||||
assertThat(violations)
|
||||
.withFailMessage(
|
||||
"transport는 execute 또는 registry 계층을 직접 호출하지 않는다. method handler를 거쳐야 한다: %s",
|
||||
violations)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* main 소스에서 주어진 import 접두사 중 하나를 사용하는 파일을 모읍니다.
|
||||
*/
|
||||
private List<Path> sourcesImportingAny(List<String> importPrefixes) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
|
||||
return paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> declaresAnyImport(path, importPrefixes))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* main 소스에서 주어진 import 접두사를 사용하는 파일을 모읍니다.
|
||||
*/
|
||||
private List<Path> sourcesImporting(String importPrefix) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(MAIN_SOURCES)) {
|
||||
return paths.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> declaresImport(path, importPrefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 해당 import 선언을 포함하는지 확인합니다. 주석이나 문자열이 아니라 import 줄만 봅니다.
|
||||
*/
|
||||
private boolean declaresImport(Path path, String importPrefix) {
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.anyMatch(line -> line.startsWith("import " + importPrefix));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일이 주어진 접두사 중 하나에 해당하는 import 선언을 포함하는지 확인합니다.
|
||||
*/
|
||||
private boolean declaresAnyImport(Path path, List<String> importPrefixes) {
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.anyMatch(line -> importPrefixes.stream()
|
||||
.anyMatch(importPrefix -> line.startsWith("import " + importPrefix)));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("소스를 읽을 수 없습니다: " + path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OS별 경로 구분자를 슬래시로 통일해 패키지 비교가 Windows에서도 동작하게 합니다.
|
||||
*/
|
||||
private String normalize(Path path) {
|
||||
return path.toString().replace('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -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