Tool inputSchema의 문서 밖 참조와 정규식 폭증을 수신 시점에 차단한다

MCP Java SDK 도입으로 들어온 json-schema-validator는 schema의 $ref가 문서
밖을 가리키면 그 주소로 직접 조회하고, pattern 검증을 백트래킹 기반
java.util.regex로 처리한다. inputSchema는 Tool Service 매니페스트에서 오므로
매니페스트가 서버의 outbound 대상과 CPU 소비를 정할 수 있었다. AGENTS.md의
"outbound 주소는 설정에서만 온다"는 불변식이 이 경로에서 뚫려 있었다.

DefaultJsonSchemaValidator는 SchemaRegistry를 생성자 안에서 만들고 private
final로 들고 있어 정책 주입 지점이 없다. 따라서 SDK 밖에서만 막을 수 있다.

검사는 ToolMetadata의 표준 생성자에 둔다. Portal 매니페스트, local 파일,
Redis snapshot 역직렬화가 모두 이 생성자를 지나므로 우회 경로가 생기지 않는다.
위반은 기존 매니페스트 형식 오류와 같게 다뤄 bundle 단위 실패 격리와
"Redis 실패는 언제나 cache miss" 동작을 그대로 유지한다.

정규식 규칙은 JDK 21.0.11 실측으로 정했다. 통념과 달리 (a+)+는 빠르게 끝나고,
중첩이 아닌 a*a*a*a*a*b와 바깥 반복이 유한한 (.*,){11}P가 폭증했다. 겹치는
문자 집합 판정은 결정 불가능하므로 모양 검사만으로는 부족하고, pattern 필드에
maxLength 동반 선언을 요구해 입력 길이를 묶는 것이 실질적인 상한이 된다.
patternProperties는 key에 길이를 선언할 자리가 없어 사용을 금지한다.

format은 단언되지 않아 format:regex 경로가 실행되지 않는다는 사실도 계약
테스트로 고정했다. SDK 업그레이드로 단언이 켜지면 테스트가 실패한다.

Tool Service는 pattern을 쓰는 필드에 maxLength(<=256)를 선언해야 하므로
매니페스트 수용 조건이 바뀐다. Tool Service 파트와 합의가 필요하다.

근거: ADR-0011, ADR-0012

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:44:01 +09:00
parent 76ed199499
commit d64516b50a
14 changed files with 858 additions and 5 deletions

View File

@@ -53,6 +53,8 @@ MCP는 Agent Builder가 `tools/call`에 명시한 단일 Tool을 실행한다. T
| `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 | | `ToolBundleDiscovery` | `registry` | 구현상 N개 Tool Service 매니페스트를 병렬 조회·검증하고 bundle별 last-good 상태를 유지. 최초 원격 조회 실패 시에만 설정된 local manifest fallback을 사용하며, 운영 배포는 1개 Bundle만 사용 |
| `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 | | `ToolBundleRegistryClient` | `registry` | 구현상 모든 bundle의 사용 가능한 성공본을 중복·총량 검증 후 하나의 snapshot으로 병합. 운영 배포에서는 단일 Bundle 결과를 채택 |
| `RedisToolRegistryCache` | `registry` | best-effort Redis snapshot, 실제 read/write 실패를 cache miss로 격리 | | `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; 실패 시 애플리케이션 생존 | | `ToolRegistryRefreshScheduler` | `registry` | 기동 preload와 주기 refresh; 실패 시 애플리케이션 생존 |
| `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 | | `ToolArgumentValidator` | `execute` | 기존 required/type 오류 계약을 보존하고 MCP SDK JSON Schema 2020-12 검증 적용 |
| `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping | | `ToolExecutionService` | `execute` | 이름 기반 metadata 해석, argument validation, 단일 Tool 실행, HTTP 경계 로그와 오류 mapping |

View File

@@ -179,7 +179,7 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
| `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 | | `name` | 예 | MCP 표준에 맞춘 `[A-Za-z0-9_./-]{1,64}`이며 bundle의 `namePrefix`로 시작해야 한다 |
| `title` | 아니오 | 표시용 이름 | | `title` | 아니오 | 표시용 이름 |
| `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 | | `description` | 예 | 에이전트가 Tool 선택에 사용한다. 언제 쓰는 도구인지 명확히 쓴다 |
| `inputSchema` | 예 | JSON Schema 2020-12 | | `inputSchema` | 예 | JSON Schema 2020-12. 아래 **schema 제약**을 만족해야 한다 |
| `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 | | `outputSchema` | 아니오 | `structuredContent` 응답 구조. 현재 MCP는 구조화 출력을 만들지 않으므로 운영에서는 사용하지 않는다 |
| `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | | `annotations` | 아니오 | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` |
| `_meta.version` | 예 | Tool 버전 | | `_meta.version` | 예 | Tool 버전 |
@@ -189,6 +189,24 @@ MCP는 이 경우 직전 매니페스트를 그대로 유지한다. **선택 기
`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`는 MCP가 `tools/list` `name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`는 MCP가 `tools/list`
그대로 공개한다. `_meta`는 공개하지 않는다. 그대로 공개한다. `_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의 `tools/call``content[0].text`만 반환하고 `structuredContent` 생성·응답 schema 검증은 하지 않는다.
MCP 2025-11-25에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는 MCP 2025-11-25에서 `outputSchema`를 선언한 서버는 이에 맞는 구조화 결과를 제공해야 하므로, Tool Service는
구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다. 구조화 출력 지원이 별도 계약으로 반영되기 전까지 운영 매니페스트에서 `outputSchema`를 생략한다.

View File

@@ -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`가 잠근다.

View File

@@ -0,0 +1,88 @@
# ADR-0012 Tool inputSchema의 정규식에 예산을 둔다
- 상태: Accepted
- 결정일: 2026-08-18
- 관련 결정: [ADR-0011](ADR-0011-tool-input-schema-stays-in-document.md) · [ADR-0006](ADR-0006-no-authentication-in-mcp.md) · [ADR-0004](ADR-0004-execution-guardrails.md)
## 배경
`inputSchema``pattern` 검증은 `java.util.regex`로 처리된다. `com.networknt:json-schema-validator`
ECMAScript 엔진은 joni나 graal-js가 있을 때만 쓰이는데 둘 다 해석하지 않으므로
([SBOM](../sbom/README.md)), 기본 경로인 `JDKRegularExpression``Pattern.compile``Matcher.find()`
호출한다. `matches()`가 아니라 `find()`라서 모든 시작 위치를 시도한다.
이 엔진은 백트래킹 기반이라 정규식과 입력의 조합에 따라 처리 시간이 폭증한다. 정규식은 Tool Service
매니페스트에서 오고 입력은 Agent Builder에서 오며, [ADR-0006](ADR-0006-no-authentication-in-mcp.md)에 따라
호출자를 걸러 주는 계층이 없다. 한 요청이 스레드를 붙잡으면 그대로 Tomcat 스레드 고갈로 이어진다.
## 측정
규칙을 감으로 정하지 않기 위해 JDK 21.0.11에서 직접 재어 보았다. 3초 안에 끝나지 않으면 HANG으로 적었다.
| 정규식 | n=100 | n=1000 | n=10000 |
|---|---|---|---|
| `a*a*b` (무한 수량자 2개) | 4ms | 481ms | **HANG** |
| `a*a*a*b` (3개) | 17ms | **HANG** | **HANG** |
| `a*a*a*a*b` (4개) | 432ms | **HANG** | **HANG** |
| `a*a*a*a*a*b` (5개) | **HANG** | **HANG** | **HANG** |
| `(.*,){11}P` | **HANG** | **HANG** | **HANG** |
| `(x+x+)+y` | 5ms | **HANG** | **HANG** |
| `^[^@ ]+@[^@ ]+$` (무한 수량자 2개) | 4ms | 4ms | **4ms** |
| `^([A-Z]{3}-)+[0-9]+$` | 1ms | 1ms | 1ms |
두 가지가 드러났다.
**첫째, 교과서적인 중첩 수량자는 생각보다 덜 위험하고 다른 형태가 더 위험하다.** `^(a+)+$`는 n=60에서도
0ms로 끝났다. 반면 중첩이 아닌 `a*a*a*a*a*b`는 n=100에서 이미 멈췄고, 바깥 반복이 11회로 **묶여 있는**
`(.*,){11}P`도 멈췄다. "중첩된 무한 수량자만 막으면 된다"는 통념대로 짰다면 정작 위험한 것을 놓쳤을 것이다.
**둘째, 개수만으로는 가를 수 없다.** `a*a*b``^[^@ ]+@[^@ ]+$`는 둘 다 무한 수량자가 2개인데 전자는
멈추고 후자는 n=10000에서도 4ms다. 차이는 수량자가 **겹치는 문자 집합**에 걸리느냐다. `@`가 경계를 만들면
되돌아갈 여지가 없다. 겹침 판정은 정적 분석 대상이고 일반적으로 결정 불가능하다.
## 결정
정규식 모양만으로는 안전을 가릴 수 없으므로, **가릴 수 있는 것은 모양으로 막고 나머지는 입력 길이로 묶는다.**
검사는 `ToolSchemaPatternPolicy``ToolMetadata` 생성 시점에 수행한다.
| 규칙 | 내용 | 근거 |
|---|---|---|
| 그룹 반복 | 무한 수량자를 품은 그룹을 다시 반복하면 거부. 바깥 반복 횟수에 상한이 있어도 거부 | `(x+x+)+y`, `(.*,){11}P` |
| 수량자 개수 | 무한 수량자 4개 이상이면 거부 | 4개는 n=1000, 5개는 n=100에서 멈춤 |
| 길이 상한 | `pattern`을 선언한 필드는 `maxLength`를 함께 선언해야 하고 256 이하여야 함 | 비용이 입력 길이를 따라 늘어남 |
| 정규식 길이 | 512자 이하 | 분석 비용을 함께 묶음 |
| 컴파일 | 등록 시점에 `Pattern.compile` | 잘못된 정규식이 요청 시점에 터지지 않게 |
| `patternProperties` | 사용 금지 | 아래 참조 |
`maxLength` 요구가 이 결정의 핵심이다. 나머지 규칙은 겹침을 판정하지 못하므로, 실질적인 상한은 길이 제한이
만든다. 상한이 없으면 요청 body 한도(약 1MB)까지 열린다.
한도 값은 설정으로 열지 않고 상수로 둔다. [ADR-0006](ADR-0006-no-authentication-in-mcp.md)의 NetworkPolicy와
같은 이유다. values 한 줄로 사라질 수 있는 통제는 통제가 아니다.
## 이 결정이 하지 않는 것
**안전을 증명하지 않는다.** 무한 수량자 3개 이하이면서 문자 집합이 겹치는 정규식은 통과하고, `maxLength`
256이면 그 조합에서 수백 ms가 걸릴 수 있다. 이 결정은 위험을 없애지 않고 **측정된 폭증 구간 밖으로 옮긴다.**
근본적인 해결은 백트래킹하지 않는 엔진(RE2 계열)으로 바꾸거나 검증에 시간 예산을 두는 것이다. 둘 다 지금
채택하지 않았다. 전자는 폐쇄망 반입 대상 의존성이 늘고 networknt가 그 엔진을 지원하는지 확인해야 하며,
후자는 `java.util.regex`가 인터럽트에 반응하지 않아 검증기 내부에 우리 `CharSequence`를 넣을 수 없으면
스레드를 버리는 방식이 된다. 필요가 생기면 이 ADR을 대체하는 새 ADR을 먼저 쓴다.
## 영향
- **Tool Service는 `pattern`을 쓰는 문자열 필드에 `maxLength`(≤256)를 함께 선언해야 한다.** 이는 매니페스트
수용 조건의 변경이므로 Tool Service 파트와 합의가 필요하다. 현재 저장소의 schema 중 `pattern`을 쓰는 것은
없어 기존 fixture는 영향을 받지 않는다.
- **`patternProperties`는 쓸 수 없다.** 이 keyword는 값이 아니라 입력 객체의 **key**에 정규식을 적용하는데,
key에는 길이를 선언할 자리가 없어 위의 `maxLength` 방식을 그대로 적용할 수 없다. `propertyNames`로 key 길이를
묶는 방법을 검토했으나, JSON Schema는 keyword 평가 순서를 정하지 않으므로 `propertyNames`가 먼저 돈다는
보장이 없다. 순서에 기대는 통제는 검증기 구현이 바뀌면 조용히 사라진다.
현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 위험을 남겨 두는 대신 쓰지 않는 기능을 닫는다.
이는 [ADR-0006](ADR-0006-no-authentication-in-mcp.md)이 검증하지 않는 인증 코드를 지운 것과 같은 판단이다.
동적 key가 실제로 필요해지면 key 길이를 묶는 방법을 정한 새 ADR을 먼저 쓴다. 부작용으로, `patternProperties`
라는 이름의 업무 필드를 가진 schema도 거부된다. 실제로 나타날 가능성이 낮아 감수한다.
- 규칙과 한도는 `ToolSchemaPatternPolicy`가 소유하고 `ToolSchemaPatternPolicyTest`가 잠근다. 위 측정을 다시
하지 않고 한도를 바꾸지 않는다.

View File

@@ -22,3 +22,5 @@
| [ADR-0008](ADR-0008-shared-host-path-routing.md) | 공유 host의 path를 독립 MCP 배포로 연결 | Superseded | | [ADR-0008](ADR-0008-shared-host-path-routing.md) | 공유 host의 path를 독립 MCP 배포로 연결 | Superseded |
| [ADR-0009](ADR-0009-container-handles-public-mcp-path.md) | 컨테이너가 공개 MCP path를 직접 처리 | Accepted | | [ADR-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-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 |

View File

@@ -34,7 +34,13 @@
1. `GET {manifestUrl}` 제공, 인증 방식과 NetworkPolicy 범위 1. `GET {manifestUrl}` 제공, 인증 방식과 NetworkPolicy 범위
2. Tool name namespace, 변경·폐기 절차와 하위 호환 기간 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 보장 4. Tool별 timeout, 권한 scope, write Tool의 idempotency 보장
5. `outputSchema`/`structuredContent` 도입 여부와 응답 검증 실패 의미 5. `outputSchema`/`structuredContent` 도입 여부와 응답 검증 실패 의미
6. 매니페스트 revision·ETag/304 및 즉시 refresh 알림의 필요성 6. 매니페스트 revision·ETag/304 및 즉시 refresh 알림의 필요성

View File

@@ -108,8 +108,23 @@ SDK 검증은 `ToolExecutionService`가 Registry 기반 argument validation을
3. 실패하면 Tool Service를 호출하지 않고 기존 `JsonRpcException(INVALID_PARAMS)`으로 종료한다. SDK 원문 오류는 3. 실패하면 Tool Service를 호출하지 않고 기존 `JsonRpcException(INVALID_PARAMS)`으로 종료한다. SDK 원문 오류는
입력값을 포함할 수 있으므로 외부에는 `arguments do not match inputSchema`만 반환한다. 입력값을 포함할 수 있으므로 외부에는 `arguments do not match inputSchema`만 반환한다.
SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다. Registry가 제공하는 SDK validator는 Spring singleton으로 한 번 생성되며 동일 schema의 컴파일 결과를 재사용한다.
schema 자체의 허용 dialect와 `$ref` 원격 해석 정책은 운영 Registry 계약으로 별도 통제해야 한다.
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 기능 ## 6. 의도적으로 도입하지 않은 SDK 기능

View File

@@ -5,7 +5,8 @@ import com.fasterxml.jackson.databind.JsonNode;
/** /**
* 내부 Tool Registry가 관리하는 한 Tool 버전의 실행 metadata를 나타내는 불변 값 객체입니다. local {@code tools/list} 파일에서 온 경우 {@code publicDefinition}은 공개 필드를 보존하고, * 내부 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) @JsonIgnoreProperties(ignoreUnknown = true)
public record ToolMetadata( public record ToolMetadata(
@@ -19,6 +20,18 @@ public record ToolMetadata(
JsonNode publicDefinition, JsonNode publicDefinition,
boolean exactEndpoint) { 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( public ToolMetadata(
String name, String name,
String version, String version,

View File

@@ -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든 같은 규칙을 통과하며, 요청 경로에는 비용을 더하지 않습니다.
*
* <p>MCP는 joni와 graal-js를 해석하지 않아 {@code pattern} 검증이 {@code java.util.regex}로 처리된다. 이 엔진은 백트래킹 기반이고 {@code Matcher.find()}로 모든 시작 위치를
* 시도하므로, 특정 정규식과 긴 입력의 조합에서 처리 시간이 다항·지수적으로 늘어난다. 인증이 없는 경계(ADR-0006)라 호출 빈도를 줄여 주는 계층도 없다.
*
* <p>규칙은 측정에 근거하며 안전을 증명하지 않는다. 근거와 한계는
* <a href="../../../../../../../../docs/decisions/ADR-0012-tool-input-schema-pattern-budget.md">ADR-0012</a>에 있다.
*/
final class ToolSchemaPatternPolicy {
/**
* 허용할 정규식 길이 상한입니다. 업무 schema가 이보다 긴 정규식을 쓰는 경우는 사실상 없습니다.
*/
private static final int MAX_PATTERN_LENGTH = 512;
/**
* 정규식 하나가 가질 수 있는 무한 수량자({@code *}, {@code +}, {@code {n,}}) 개수 상한입니다. 겹치는 문자 집합에 이런 수량자가 k개 이어지면 비용이 입력 길이의 k제곱으로 늘어납니다. 측정상 4개부터는
* 아래 입력 상한 안에서도 초 단위로 넘어가고, 정상 업무 정규식이 3개를 넘는 경우는 드뭅니다.
*/
private static final int MAX_UNBOUNDED_QUANTIFIERS = 3;
/**
* {@code pattern}을 선언한 문자열 필드가 함께 선언해야 하는 {@code maxLength}의 상한입니다. 비용이 입력 길이에 달려 있으므로, 길이를 묶는 것이 정규식 모양을 검사하는 것보다 확실합니다.
*/
private static final int MAX_PATTERNED_STRING_LENGTH = 256;
private ToolSchemaPatternPolicy() {}
/**
* schema 전체를 훑어 {@code pattern} 정규식과 그 필드의 길이 제한을 검사하고, 길이를 묶을 수 없는 {@code patternProperties}는 거부합니다. schema가 없으면 통과시키고, 위반을 찾으면 해당 Tool을
* 등록하지 못하도록 {@link IllegalStateException}을 던집니다. 호출자는 이 예외를 기존 매니페스트 형식 오류와 같게 다루므로 bundle 단위 실패 격리와 Redis cache miss 동작이 그대로
* 적용됩니다.
*/
static void assertPatternsTerminateQuickly(JsonNode schema) {
if (schema == null || schema.isNull()) {
return;
}
Deque<JsonNode> pending = new ArrayDeque<>();
pending.push(schema);
while (!pending.isEmpty()) {
JsonNode node = pending.pop();
if (node.isObject()) {
assertKeywordPattern(node);
assertPatternPropertiesAbsent(node);
}
node.forEach(pending::push);
}
}
/**
* 한 schema 객체의 {@code pattern}과 그 필드의 {@code maxLength}를 함께 검사합니다. 값이 문자열이 아니면 정규식이 아니라 {@code properties} 아래에 우연히 같은 이름을 쓴 필드
* 정의이므로 건너뜁니다.
*/
private static void assertKeywordPattern(JsonNode node) {
JsonNode pattern = node.get("pattern");
if (pattern == null || !pattern.isTextual()) {
return;
}
assertSafeRegex(pattern.asText());
assertBoundedLength(node.get("maxLength"));
}
/**
* 정규식이 걸린 문자열의 길이가 묶여 있는지 확인합니다. 검증 비용이 입력 길이를 따라 늘어나므로, 상한이 없으면 정규식 모양과 무관하게 요청 body 한도(약 1MB)까지 열려 버립니다.
*/
private static void assertBoundedLength(JsonNode maxLength) {
if (maxLength == null || !maxLength.isIntegralNumber()) {
throw new IllegalStateException("Tool inputSchema pattern requires maxLength on the same field");
}
if (maxLength.intValue() <= 0 || maxLength.intValue() > MAX_PATTERNED_STRING_LENGTH) {
throw new IllegalStateException(
"Tool inputSchema maxLength with pattern must be at most " + MAX_PATTERNED_STRING_LENGTH);
}
}
/**
* {@code patternProperties}를 아예 거부합니다. 이 keyword는 값이 아니라 <em>입력 객체의 key</em>에 정규식을 적용하는데, key 길이를 선언할 자리가 없어 {@code pattern}에 쓴 길이
* 상한 방식을 그대로 적용할 수 없습니다. {@code propertyNames}로 길이를 묶는 방법은 keyword 평가 순서가 명세에 정해져 있지 않아 정규식이 먼저 돌 수 있으므로 통제로 쓰지 않습니다.
*
* <p>현재 어떤 Tool도 이 keyword를 쓰지 않으므로, 묶을 수 없는 것을 남겨 두는 대신 쓰지 않는 기능을 닫습니다. 실제 필요가 생기면 길이를 묶는 방법을 정한 새 ADR을 먼저 씁니다.
*/
private static void assertPatternPropertiesAbsent(JsonNode node) {
if (node.has("patternProperties")) {
throw new IllegalStateException("Tool inputSchema must not use patternProperties");
}
}
/**
* 정규식 하나가 길이 상한과 컴파일 가능성을 만족하고, 반복 구조가 허용 범위인지 확인합니다. 컴파일을 여기서 해 두면 잘못된 정규식이 요청 시점이 아니라 등록 시점에 걸립니다.
*/
private static void assertSafeRegex(String regex) {
if (regex.length() > MAX_PATTERN_LENGTH) {
throw new IllegalStateException(
"Tool inputSchema pattern must be at most " + MAX_PATTERN_LENGTH + " characters");
}
try {
Pattern.compile(regex);
} catch (PatternSyntaxException exception) {
throw new IllegalStateException("Tool inputSchema pattern is not a valid regular expression");
}
assertRepetitionIsBudgeted(regex);
}
/**
* 두 가지 반복 구조를 거부합니다.
*
* <ol>
* <li>무한 수량자를 품은 그룹을 다시 반복하는 형태. {@code (x+x+)+y}와 {@code (.*,){11}P}가 여기 해당하며, 바깥 반복 횟수에 상한이 있어도 측정상 폭증했으므로 {@code {11}} 같은
* 유한 반복도 함께 막습니다.
* <li>무한 수량자가 {@value #MAX_UNBOUNDED_QUANTIFIERS}개를 넘는 형태. {@code a*a*a*a*a*b}처럼 겹치는 문자 집합에 수량자가 이어지는 경우를 줄입니다.
* </ol>
*
* <p>겹침 여부까지 판정하지는 않으므로 이 검사만으로 안전이 보장되지 않습니다. 실질적인 상한은 함께 적용하는 {@code maxLength} 제한이 만듭니다.
*/
private static void assertRepetitionIsBudgeted(String regex) {
// 각 원소는 "지금까지 이 그룹 안에서 무한 수량자를 봤는가"다.
Deque<Boolean> openGroups = new ArrayDeque<>();
boolean insideCharacterClass = false;
int unboundedQuantifiers = 0;
int index = 0;
while (index < regex.length()) {
char current = regex.charAt(index);
if (current == '\\') {
// 이스케이프된 문자는 수량자도 그룹도 아니다.
index += 2;
continue;
}
if (insideCharacterClass) {
insideCharacterClass = current != ']';
index++;
continue;
}
if (current == '[') {
insideCharacterClass = true;
index++;
continue;
}
if (current == '(') {
openGroups.push(Boolean.FALSE);
index++;
continue;
}
if (current == ')') {
boolean groupHasUnbounded = !openGroups.isEmpty() && openGroups.pop();
int unbounded = unboundedQuantifierLength(regex, index + 1);
int bounded = unbounded > 0 ? 0 : boundedQuantifierLength(regex, index + 1);
if (groupHasUnbounded && (unbounded > 0 || bounded > 0)) {
throw new IllegalStateException(
"Tool inputSchema pattern must not repeat a group that already repeats without bound");
}
if (unbounded > 0) {
unboundedQuantifiers++;
markEnclosingGroup(openGroups);
}
index += 1 + unbounded + bounded;
continue;
}
int unbounded = unboundedQuantifierLength(regex, index);
if (unbounded > 0) {
unboundedQuantifiers++;
markEnclosingGroup(openGroups);
index += unbounded;
} else {
index++;
}
}
if (unboundedQuantifiers > MAX_UNBOUNDED_QUANTIFIERS) {
throw new IllegalStateException(
"Tool inputSchema pattern must use at most "
+ MAX_UNBOUNDED_QUANTIFIERS
+ " unbounded quantifiers");
}
}
/**
* 지금 열려 있는 그룹에 무한 수량자를 봤다고 기록합니다. 그룹 밖이면 중첩 판정 대상이 없습니다.
*/
private static void markEnclosingGroup(Deque<Boolean> openGroups) {
if (!openGroups.isEmpty()) {
openGroups.pop();
openGroups.push(Boolean.TRUE);
}
}
/**
* 주어진 위치에서 시작하는 무한 수량자({@code *}, {@code +}, {@code {n,}})의 길이를 반환하고, 아니면 0을 반환합니다.
*/
private static int unboundedQuantifierLength(String regex, int start) {
if (start >= regex.length()) {
return 0;
}
char current = regex.charAt(start);
if (current == '*' || current == '+') {
return 1;
}
if (current != '{') {
return 0;
}
int close = regex.indexOf('}', start);
if (close < 0) {
return 0;
}
return regex.substring(start + 1, close).endsWith(",") ? close - start + 1 : 0;
}
/**
* 주어진 위치에서 시작하는 상한 있는 수량자({@code ?}, {@code {n}}, {@code {n,m}})의 길이를 반환하고, 아니면 0을 반환합니다.
*/
private static int boundedQuantifierLength(String regex, int start) {
if (start >= regex.length()) {
return 0;
}
if (regex.charAt(start) == '?') {
return 1;
}
if (regex.charAt(start) != '{') {
return 0;
}
int close = regex.indexOf('}', start);
return close < 0 ? 0 : close - start + 1;
}
}

View File

@@ -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<JsonNode> pending = new ArrayDeque<>();
pending.push(schema);
while (!pending.isEmpty()) {
JsonNode node = pending.pop();
if (node.isObject()) {
assertReferencesStayInDocument(node);
assertDialectIsSupported(node);
}
node.forEach(pending::push);
}
}
/**
* 한 schema 객체의 참조 keyword가 같은 문서 안을 가리키는지 확인합니다. 문자열이 아닌 값은 참조가 아니라 {@code properties} 아래의 필드 정의이므로 건너뜁니다.
*/
private static void assertReferencesStayInDocument(JsonNode node) {
for (String keyword : REFERENCE_KEYWORDS) {
JsonNode reference = node.get(keyword);
if (reference != null && reference.isTextual() && !reference.asText().startsWith("#")) {
throw new IllegalStateException(
"Tool inputSchema " + keyword + " must stay inside the document");
}
}
}
/**
* schema가 선언한 dialect가 MCP가 쓰는 2020-12인지 확인합니다. 선언이 없으면 검증기의 기본 dialect가 적용되므로 통과시킵니다.
*/
private static void assertDialectIsSupported(JsonNode node) {
JsonNode dialect = node.get("$schema");
if (dialect != null && dialect.isTextual() && !SUPPORTED_DIALECT.equals(dialect.asText())) {
throw new IllegalStateException("Tool inputSchema $schema must be " + SUPPORTED_DIALECT);
}
}
}

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dap.biz.mcp.execute;
import static io.shinhanlife.dap.biz.mcp.TestFixtures.OBJECT_MAPPER; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
@@ -74,4 +75,38 @@ class ToolArgumentValidatorTest {
assertThat(exception.errorData().toString()).doesNotContain("unexpected"); 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();
}
} }

View File

@@ -84,6 +84,31 @@ class ToolBundleDiscoveryTest {
.satisfies(tool -> assertThat(tool.endpoint()).isEqualTo("http://tool-a/mcp")); .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 @Test
void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() { void rejectsTheAggregateWhenABundleHasNoLastGoodSnapshot() {
alpha.enqueue(new MockResponse().setResponseCode(503)); alpha.enqueue(new MockResponse().setResponseCode(503));

View File

@@ -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);
}
}

View File

@@ -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);
}
}