From ad7fccbed1be071b7b5fe7ad0dd2ba2fa89ab60e Mon Sep 17 00:00:00 2001 From: koseokmin Date: Tue, 15 Sep 2026 17:20:50 +0900 Subject: [PATCH] =?UTF-8?q?Tool=20inputSchema=EC=97=90=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=C2=B7=EC=A0=95=EA=B7=9C=EC=8B=9D=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매니페스트가 선언한 inputSchema는 외부가 정하는 입력이다. JSON Schema 검증기는 문서 밖 $ref를 만나면 그 주소로 직접 조회하므로 매니페스트가 서버의 outbound 호출 대상을 정하는 통로가 된다. pattern은 joni와 graal-js가 없어 java.util.regex의 백트래킹 경로로 처리되고, 인증이 없는 경계(ADR-0006)라 호출 빈도를 줄여 주는 계층도 없다. ToolSchemaReferencePolicy가 문서 밖 참조와 미지원 dialect를 막고, ToolSchemaPatternPolicy가 정규식 길이·무한 수량자 개수·중첩 반복을 검사하며 pattern을 쓰는 필드에 maxLength를 요구한다. 길이를 묶을 수 없는 patternProperties는 거부한다. 검사는 ToolMetadata의 표준 생성자 한 곳에서만 한다. Portal 매니페스트 파싱, local 파일 로딩, Redis snapshot 역직렬화가 모두 이 생성자를 지나므로 조회 경로가 늘어도 검사 지점은 하나로 남는다. 위반은 IllegalStateException이라 기존 매니페스트 형식 오류와 같게 다뤄지고 bundle 단위 실패 격리가 그대로 적용된다. 근거와 한계는 ADR-0011, ADR-0012에 있다. ADR-0012가 classpath 근거로 인용하는 docs/sbom도 함께 가져온다. 192개 테스트 전부 통과. 기존 169건은 새 검사에 걸리지 않는다. Co-Authored-By: Claude Opus 5 --- ...011-tool-input-schema-stays-in-document.md | 72 ++ ...R-0012-tool-input-schema-pattern-budget.md | 88 +++ docs/decisions/README.md | 2 + ...UB_MCP_Tool_Service_SBOM_CycloneDX1.5.json | 706 ++++++++++++++++++ ...UB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx | Bin 0 -> 15743 bytes docs/sbom/README.md | 74 ++ .../dat/biz/mcp/registry/ToolMetadata.java | 9 + .../mcp/registry/ToolSchemaPatternPolicy.java | 244 ++++++ .../registry/ToolSchemaReferencePolicy.java | 80 ++ .../registry/ToolSchemaPatternPolicyTest.java | 166 ++++ .../ToolSchemaReferencePolicyTest.java | 111 +++ 11 files changed, 1552 insertions(+) create mode 100644 docs/decisions/ADR-0011-tool-input-schema-stays-in-document.md create mode 100644 docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md create mode 100644 docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json create mode 100644 docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx create mode 100644 docs/sbom/README.md create mode 100644 src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicy.java create mode 100644 src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicy.java create mode 100644 src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicyTest.java create mode 100644 src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicyTest.java diff --git a/docs/decisions/ADR-0011-tool-input-schema-stays-in-document.md b/docs/decisions/ADR-0011-tool-input-schema-stays-in-document.md new file mode 100644 index 0000000..ba2b138 --- /dev/null +++ b/docs/decisions/ADR-0011-tool-input-schema-stays-in-document.md @@ -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`가 잠근다. diff --git a/docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md b/docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md new file mode 100644 index 0000000..b61f1e2 --- /dev/null +++ b/docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md @@ -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`가 잠근다. 위 측정을 다시 + 하지 않고 한도를 바꾸지 않는다. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 4244e76..0dcb43d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,3 +21,5 @@ | [ADR-0007](ADR-0007-one-mcp-per-tool-service.md) | MCP 배포 하나는 Tool Service 하나만 본다 | Accepted | | [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-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 | diff --git a/docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json b/docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json new file mode 100644 index 0000000..38a4e8d --- /dev/null +++ b/docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.json @@ -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": [] + } + ] +} diff --git a/docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx b/docs/sbom/AXHUB_MCP_Tool_Service_SBOM_CycloneDX1.5.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2a2c2834e5a44a41e71676896da740a0c237a780 GIT binary patch literal 15743 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 Qg8ThN`W;m@xc@r)e{;|=EdT%j literal 0 HcmV?d00001 diff --git a/docs/sbom/README.md b/docs/sbom/README.md new file mode 100644 index 0000000..f95bf09 --- /dev/null +++ b/docs/sbom/README.md @@ -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`에 등장하는지 구조 점검을 통과시켰다. diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java index 8b02f88..49ff745 100644 --- a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolMetadata.java @@ -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); + } + /** * 입력값을 내부 처리 형식으로 변환합니다. * diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicy.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicy.java new file mode 100644 index 0000000..88e231f --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicy.java @@ -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든 같은 규칙을 통과하며, 요청 경로에는 비용을 더하지 않습니다. + * * + * *

MCP는 joni와 graal-js를 해석하지 않아 {@code pattern} 검증이 {@code java.util.regex}로 처리된다. 이 엔진은 백트래킹 기반이고 {@code Matcher.find()}로 모든 시작 위치를 + * 시도하므로, 특정 정규식과 긴 입력의 조합에서 처리 시간이 다항·지수적으로 늘어난다. 인증이 없는 경계(ADR-0006)라 호출 빈도를 줄여 주는 계층도 없다. + * * + * *

규칙은 측정에 근거하며 안전을 증명하지 않는다. 근거와 한계는 + * ADR-0012에 있다. + * @author k.s.m + * @create 2026.09.15 + * + *

+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.15   k.s.m         최초생성
+ *
+ * 
+ */ +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 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는 값이 아니라 입력 객체의 key에 정규식을 적용하는데, key 길이를 선언할 자리가 없어 {@code pattern}에 쓴 길이 + * 상한 방식을 그대로 적용할 수 없습니다. {@code propertyNames}로 길이를 묶는 방법은 keyword 평가 순서가 명세에 정해져 있지 않아 정규식이 먼저 돌 수 있으므로 통제로 쓰지 않습니다. + * + *

현재 어떤 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); + } + + /** + * 두 가지 반복 구조를 거부합니다. + * + *

    + *
  1. 무한 수량자를 품은 그룹을 다시 반복하는 형태. {@code (x+x+)+y}와 {@code (.*,){11}P}가 여기 해당하며, 바깥 반복 횟수에 상한이 있어도 측정상 폭증했으므로 {@code {11}} 같은 + * 유한 반복도 함께 막습니다. + *
  2. 무한 수량자가 {@value #MAX_UNBOUNDED_QUANTIFIERS}개를 넘는 형태. {@code a*a*a*a*a*b}처럼 겹치는 문자 집합에 수량자가 이어지는 경우를 줄입니다. + *
+ * + *

겹침 여부까지 판정하지는 않으므로 이 검사만으로 안전이 보장되지 않습니다. 실질적인 상한은 함께 적용하는 {@code maxLength} 제한이 만듭니다. + */ + private static void assertRepetitionIsBudgeted(String regex) { + // 각 원소는 "지금까지 이 그룹 안에서 무한 수량자를 봤는가"다. + Deque 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 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; + } +} diff --git a/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicy.java b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicy.java new file mode 100644 index 0000000..b312dfa --- /dev/null +++ b/src/main/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicy.java @@ -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 + * + *

+ * ============ 개정이력 ============
+ * 수정일        수정자        수정내용
+ * ----------   ----------    ----------------
+ * 2026.09.15   k.s.m         최초생성
+ *
+ * 
+ */ +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 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); + } + } +} diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicyTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicyTest.java new file mode 100644 index 0000000..4b64778 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaPatternPolicyTest.java @@ -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); + } +} diff --git a/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicyTest.java b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicyTest.java new file mode 100644 index 0000000..1a534f5 --- /dev/null +++ b/src/test/java/io/shinhanlife/dat/biz/mcp/registry/ToolSchemaReferencePolicyTest.java @@ -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); + } +}