diff --git a/docs/architecture.md b/docs/architecture.md
index bb85425..e18fa37 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -53,6 +53,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 |
| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 |
| `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 |
+| `ToolSchemaPatternPolicy` | `registry` | `ToolMetadata` 생성 시점에 `pattern` 정규식의 반복 구조·개수·길이와 대상 필드의 `maxLength`를 검사해 정규식 검증이 요청 스레드를 오래 붙잡지 못하게 한다([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)) |
+| `ToolSchemaReferencePolicy` | `registry` | `ToolMetadata` 생성 시점에 `inputSchema`가 문서 밖을 참조하지 못하게 차단. 매니페스트가 검증기의 조회 대상을 정하는 통로를 막는다([ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)) |
| `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 시 애플리케이션 생존 |
| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 |
| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping |
diff --git a/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md b/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md
index 00c719e..2f5614b 100644
--- a/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md
+++ b/docs/contracts/tool-service-mcp/protocol-v0.2-bundle-discovery.md
@@ -179,7 +179,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 |
| `title` | 아니오 | 표시용 이름 |
| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 |
-| `inputSchema` | 예 | JSON Schema 2020-12 |
+| `inputSchema` | 예 | JSON Schema 2020-12. 아래 **schema 제약**을 만족해야 한다 |
| `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 |
| `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` |
| `_meta.version` | 예 | Tool 버전 |
@@ -189,6 +189,24 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`는 MCP가 `tools/list`로
그대로 공개한다. `_meta`는 공개하지 않는다.
+#### schema 제약
+
+MCP는 `inputSchema`를 검증기에 넘기기 전에 다음을 확인하고, 어기면 그 Tool이 실린 bundle을 실패로 처리한다.
+매니페스트 형식 오류와 같은 취급이므로 다른 bundle의 정상 Tool은 영향을 받지 않는다.
+
+| 제약 | 내용 | 근거 |
+|---|---|---|
+| 문서 안 참조만 | `$ref`·`$dynamicRef`는 `#`으로 시작해야 한다. 공통 타입은 같은 문서의 `$defs`에 둔다 | [ADR-0011](../../decisions/ADR-0011-tool-input-schema-stays-in-document.md) |
+| dialect 고정 | `$schema`를 선언하면 `https://json-schema.org/draft/2020-12/schema`여야 한다 | ADR-0011 |
+| 정규식 반복 | 무한 수량자(`*`, `+`, `{n,}`)를 품은 그룹을 다시 반복할 수 없다. 바깥 반복 횟수가 유한해도 같다 | [ADR-0012](../../decisions/ADR-0012-tool-input-schema-pattern-budget.md) |
+| 정규식 수량자 | 무한 수량자는 정규식 하나당 3개까지 | ADR-0012 |
+| 정규식 길이 | `pattern`은 512자 이하이며 컴파일 가능해야 한다 | ADR-0012 |
+| 길이 상한 동반 | `pattern`을 선언한 필드는 `maxLength`를 함께 선언해야 하고 값은 256 이하 | ADR-0012 |
+| `patternProperties` 금지 | 이 keyword는 사용할 수 없다. 고정 key를 `properties`로 선언한다 | ADR-0012 |
+
+마지막 항목이 가장 자주 걸린다. `{"type":"string","pattern":"^[0-9]{10}$"}`는 거부되고
+`{"type":"string","maxLength":10,"pattern":"^[0-9]{10}$"}`는 통과한다.
+
현재 MCP의 `tools/call`은 `content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다.
MCP 2025-11-25에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는
구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다.
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 e77fc98..f8f4d9d 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -22,3 +22,5 @@
| [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-portal-owns-route-and-endpoint-registry.md) | Tool Server endpoint 목록과 route 매핑의 원천은 Portal | 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/extension-points.md b/docs/extension-points.md
index 1bb1bc4..d61c3fd 100644
--- a/docs/extension-points.md
+++ b/docs/extension-points.md
@@ -34,7 +34,13 @@
1. `GET {manifestUrl}` 제공, 인증 방식과 NetworkPolicy 범위
2. Tool name namespace, 변경·폐기 절차와 하위 호환 기간
-3. 허용할 JSON Schema 2020-12 keyword, 원격 `$ref`와 `format` 정책
+3. 허용할 JSON Schema 2020-12 keyword와 `format` 정책. **현재 SDK 검증기는 `format`을 단언하지 않는다.**
+ `format: "date-time"`에 아무 문자열이나 넣어도 통과하므로, Tool Service가 이를 입력 검증 수단으로
+ 기대하면 안 된다. 단언을 켤지, 아니면 `pattern`으로 대체할지 정해야 한다. 현재 동작은
+ `ToolArgumentValidatorTest`가 고정한다. 문서 밖 `$ref`는
+ [ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)로, `pattern`의 반복 예산과
+ `maxLength` 동반 선언 요구는 [ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)로
+ 확정했다. **ADR-0012는 매니페스트 수용 조건을 바꾸므로 Tool Service 파트와 합의가 필요하다**
4. Tool별 timeout, 권한 scope, write Tool의 idempotency 보장
5. `outputSchema`/`structuredContent` 도입 여부와 응답 검증 실패 의미
6. 매니페스트 revision·ETag/304 및 즉시 refresh 알림의 필요성
diff --git a/docs/mcp-java-sdk-adoption.md b/docs/mcp-java-sdk-adoption.md
index e0f94b6..07a3f56 100644
--- a/docs/mcp-java-sdk-adoption.md
+++ b/docs/mcp-java-sdk-adoption.md
@@ -108,8 +108,23 @@ SDK 검증은 `ToolExecutionService`가 Registry 기반 argument validation을
3. 실패하면 Tool Service를 호출하지 않고 기존 `JsonRpcException(INVALID_PARAMS)`으로 종료한다. SDK 원문 오류는
입력값을 포함할 수 있으므로 외부에는 `arguments do not match inputSchema`만 반환한다.
-SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다. Registry가 제공하는
-schema 자체의 허용 dialect와 `$ref` 원격 해석 정책은 운영 Registry 계약으로 별도 통제해야 한다.
+SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다.
+
+Registry가 제공하는 schema 자체의 허용 dialect와 `$ref` 해석 범위는
+[ADR-0011](decisions/ADR-0011-tool-input-schema-stays-in-document.md)로 확정했다. `ToolSchemaReferencePolicy`가
+`ToolMetadata` 생성 시점에 문서 밖 `$ref`·`$dynamicRef`와 2020-12가 아닌 `$schema`를 거부하므로, 검증기가 schema에
+적힌 주소로 조회를 시도할 수 있는 경로가 남지 않는다. `DefaultJsonSchemaValidator`는 `SchemaRegistry`를 내부에서
+생성해 정책 주입 지점을 열어 두지 않으므로, 이 통제는 SDK 밖에서만 걸 수 있다. `format` 키워드의 검증 강도는 아직
+협의 항목이다.
+
+`pattern` 정규식은 joni·graal-js를 해석하지 않아 `java.util.regex`로 검증된다. 백트래킹 폭증을 막기 위해
+`ToolSchemaPatternPolicy`가 반복 구조·수량자 개수·정규식 길이를 제한하고 `maxLength` 동반 선언을 요구한다
+([ADR-0012](decisions/ADR-0012-tool-input-schema-pattern-budget.md)). 측정 근거와 남는 위험은 그 ADR에 있다.
+
+`format`은 단언하지 않는다. 2020-12에서 format-assertion은 opt-in이고 SDK 검증기가 이를 켜지 않으므로,
+`format: "date-time"`이나 `format: "ipv4"`에 임의 문자열을 넣어도 통과한다. 덕분에 입력 값을 정규식으로
+컴파일하는 `format: "regex"` 경로도 실행되지 않는다. 이 동작은 `ToolArgumentValidatorTest`가 고정하므로,
+SDK 업그레이드로 단언이 켜지면 테스트가 실패해 알 수 있다.
## 6. 의도적으로 도입하지 않은 SDK 기능
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java
index 9f2db20..969ce2f 100644
--- a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolMetadata.java
@@ -5,7 +5,8 @@ import com.fasterxml.jackson.databind.JsonNode;
/**
* 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고,
- * {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다.
+ * {@code tools/call}에는 endpoint·timeout·schema 정책까지 포함해 사용됩니다. 생성 시점에 {@link ToolSchemaReferencePolicy}와 {@link ToolSchemaPatternPolicy}로
+ * {@code inputSchema}를 검사하므로, 어느 조회 경로로 들어온 metadata든 문서 밖을 가리키는 참조나 되돌아오는 데 오래 걸리는 정규식을 담은 채로는 만들어지지 않습니다.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ToolMetadata(
@@ -19,6 +20,18 @@ 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);
+ }
+
+ /**
+ * {@code exactEndpoint}를 쓰지 않는 호출자를 위해 기본값 {@code false}로 표준 생성자에 위임합니다.
+ */
public ToolMetadata(
String name,
String version,
diff --git a/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaPatternPolicy.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaPatternPolicy.java
new file mode 100644
index 0000000..a89bff5
--- /dev/null
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaPatternPolicy.java
@@ -0,0 +1,232 @@
+package io.shinhanlife.dap.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;
+
+/**
+ * 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에 있다.
+ */
+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);
+ }
+
+ /**
+ * 두 가지 반복 구조를 거부합니다.
+ *
+ *
+ * - 무한 수량자를 품은 그룹을 다시 반복하는 형태. {@code (x+x+)+y}와 {@code (.*,){11}P}가 여기 해당하며, 바깥 반복 횟수에 상한이 있어도 측정상 폭증했으므로 {@code {11}} 같은
+ * 유한 반복도 함께 막습니다.
+ *
- 무한 수량자가 {@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/dap/biz/mcp/registry/ToolSchemaReferencePolicy.java b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaReferencePolicy.java
new file mode 100644
index 0000000..69dbe96
--- /dev/null
+++ b/src/main/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaReferencePolicy.java
@@ -0,0 +1,68 @@
+package io.shinhanlife.dap.biz.mcp.registry;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Tool의 {@code inputSchema}가 문서 밖을 가리키는 참조를 담지 못하게 막는 정책입니다. {@link ToolMetadata}가 만들어질 때만 호출되므로 Portal 매니페스트, local 파일, Redis snapshot 중 어느 경로로 들어온
+ * schema든 같은 규칙을 통과합니다. JSON Schema 검증기는 문서 밖 참조를 만나면 그 주소로 직접 조회를 시도하므로, 매니페스트가 서버의 outbound 호출 대상을 정하는 통로가 되지 않도록 수신 시점에 끊습니다.
+ */
+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/dap/biz/mcp/execute/ToolArgumentValidatorTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java
index 576b80a..714f2ab 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/execute/ToolArgumentValidatorTest.java
@@ -2,6 +2,7 @@ package io.shinhanlife.dap.biz.mcp.execute;
import static io.shinhanlife.dap.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 io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
@@ -74,4 +75,38 @@ class ToolArgumentValidatorTest {
assertThat(exception.errorData().toString()).doesNotContain("unexpected");
});
}
+
+ @Test
+ void doesNotAssertFormatSoToolServicesCannotRelyOnIt() throws Exception {
+ // JSON Schema 2020-12에서 format은 기본이 주석이고, SDK 검증기도 단언하지 않는다.
+ // 두 가지를 고정하기 위한 테스트다.
+ // 1. Tool Service가 format을 입력 검증 수단으로 기대하면 안 된다는 사실.
+ // 2. format:regex는 입력 값을 정규식으로 컴파일할 수 있는 형태인데, 단언이 꺼져 있어
+ // 그 경로가 실행되지 않는다는 사실. SDK 업그레이드나 설정 변경으로 단언이 켜지면
+ // 이 테스트가 실패하므로, 그때 ADR-0012의 정규식 예산과 함께 다시 판단한다.
+ ToolCall call =
+ new ToolCall(
+ "document.search",
+ OBJECT_MAPPER.readTree("""
+ {"issuedAt":"not-a-date","expr":"([unclosed"}
+ """));
+ ToolMetadata metadata =
+ new ToolMetadata(
+ "document.search",
+ "1.0.0",
+ "Search documents",
+ "http://tool.example/search",
+ OBJECT_MAPPER.readTree(
+ """
+ {"type":"object","properties":{
+ "issuedAt":{"type":"string","format":"date-time"},
+ "expr":{"type":"string","format":"regex"}}}
+ """),
+ 3_000,
+ true,
+ null);
+
+ assertThatCode(() -> validator.validate(call, metadata)).doesNotThrowAnyException();
+ }
+
}
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java
index 997cb54..ed6c6e4 100644
--- a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolBundleDiscoveryTest.java
@@ -84,6 +84,31 @@ class ToolBundleDiscoveryTest {
.satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp"));
}
+ @Test
+ void rejectsAManifestWhoseInputSchemaReferencesAnExternalDocument() {
+ // endpoint와 마찬가지로 schema 참조도 매니페스트가 outbound 대상을 정하는 통로가 되면 안 된다.
+ alpha.enqueue(
+ new MockResponse()
+ .setHeader("Content-Type", "application/json")
+ .setBody(
+ """
+ {"bundleId":"bundle-a","tools":[
+ {"name":"a.search","description":"search",
+ "inputSchema":{"type":"object",
+ "properties":{"q":{"$ref":"http://attacker.example/schema.json"}}},
+ "_meta":{"version":"1.0.0"}}]}
+ """));
+ McpProperties properties =
+ withBundles(bundle("bundle-a", url(alpha), "http://tool-a/mcp", "a."));
+
+ assertThatThrownBy(() -> client(properties).fetchTools())
+ .isInstanceOfSatisfying(
+ JsonRpcException.class,
+ exception ->
+ assertThat(exception.errorCode())
+ .isEqualTo(JsonRpcErrorCode.TOOL_REGISTRY_UNAVAILABLE));
+ }
+
@Test
void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() {
alpha.enqueue(new MockResponse().setResponseCode(503));
diff --git a/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaPatternPolicyTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaPatternPolicyTest.java
new file mode 100644
index 0000000..496d07f
--- /dev/null
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaPatternPolicyTest.java
@@ -0,0 +1,166 @@
+package io.shinhanlife.dap.biz.mcp.registry;
+
+import static io.shinhanlife.dap.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/dap/biz/mcp/registry/ToolSchemaReferencePolicyTest.java b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaReferencePolicyTest.java
new file mode 100644
index 0000000..f152108
--- /dev/null
+++ b/src/test/java/io/shinhanlife/dap/biz/mcp/registry/ToolSchemaReferencePolicyTest.java
@@ -0,0 +1,111 @@
+package io.shinhanlife.dap.biz.mcp.registry;
+
+import static io.shinhanlife.dap.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);
+ }
+}