블로그 • MCP / TypeScript SDK
MCP TypeScript SDK 2.4.0으로 올린 뒤, tools/call의 structuredContent는 JSON 어느 층을 Schema로 검증해야 하나?
2026년 10월 23일 MCP TypeScript SDK는 @modelcontextprotocol/server 2.4.0을 냈다. 프로토콜 개정 2025-11-25에서 z.object().meta({ id })로 출력을 선언한 도구의 structuredContent는 result로 감싸지 않고 루트에 필드가 온다.
많은 감사 스크립트가 structuredContent.result에 Ajv를 걸어, 클라이언트 업그레이드 뒤 합법 응답이 빨갛게 된다. 구 서버나 미재시작 프로세스는 result legacy를 줄 수 있다. protocol과 metaIdTool로 validate_root와 legacy_wrapper를 고른다.
MCP 연결과 자격 증명 경계는 MCP 보안. schema만 맞아도 부족한 이야기는 schema 부족. Bedrock 구조화 출력은 다른 봉투이며 Bedrock 구조화 출력. 이 글은 2.4.0 이후 structuredContent Schema 층과 게이트 분기만 다룬다.
- 2.4.0 클라이언트는 2025-11-25에서 meta id 도구의 structuredContent 루트가 출력 payload라고 본다.
- .result만 검증하는 파이프라인은 업그레이드 후 오탐한다. 응답 형태를 보고 schema 경로를 고른다.
- legacy 서버는 result 단일 키를 줄 수 있다. 게이트는 legacy_wrapper를 반환하고 result 안을 Schema 검증한다.
- 출력 JSON Schema $id에 my_tool:output 같은 URI를 피한다. Ajv 3.1.8과 fast-uri가 거부한다. 밑줄이나 $defs를 쓴다.
먼저 이것만: 2.4.0이 바꾸는 것은 2025-11-25의 meta id 도구 structuredContent 형태이지 tools/list 자체가 아니다. 아래는 릴리스, unwrap 비교, 감사 레코드, $id 함정, 게이트 반환, JSONNote에서 두 JSON 층 대조.
2.4.0은 2025-11-25에서 meta id 도구 structuredContent를 unwrap
변경과 마이그레이션은 @modelcontextprotocol/server 2.4.0 릴리스. TypeScript SDK는 meta id 출력 schema에 result 래퍼를 자동 삽입하지 않는다. 서버가 result 단일 키를 주면 legacy_wrapper를 식별한다.
같은 릴리스에서 Ajv 의존이 강화되어 fast-uri 3.1.8이 $id를 URI로 파싱해 my_tool:output을 거부한다. $id에 콜론 대신 my_tool_output이나 $defs를 쓴다. unwrap과 무관하지만 같은 검증 파이프라인에서 터진다.
unwrap 후 structuredContent는?
프로토콜 2025-11-25에서 2.4.0 클라이언트는 region, count 같은 필드를 structuredContent 루트에 기대한다. 첫 샘플은 업그레이드 후, 둘째는 legacy result 래퍼다.
{
"structuredContent": {
"region": "apac",
"count": 42
}
}
{
"structuredContent": {
"result": {
"region": "apac",
"count": 42
}
}
}
모든 응답이 unwrap됐다고 가정하지 않는다. result만 있고 payload가 안에 있으면 내부에 출력 schema를 돌리고 legacy_wrapper를 로그해 미재시작 서버를 집계한다.
감사 레코드는 tools/call을 어떻게 적나?
게이트는 protocol, 도구명, z.object().meta({ id }) 출력 여부가 필요하다. 샘플은 metaIdTool true, protocol 2025-11-25. tool 누락이나 protocol 오류는 validate_root를 강제하지 말고 schema를 먼저 반환한다.
{
"tool": "get_report",
"protocol": "2025-11-25",
"metaIdTool": true
}
아래 schema로 감사 레코드 형태를 고정한다. metaIdTool false 일반 도구도 validate_root지만 structuredContent에 필드가 없으면 missing으로 처리하고 legacy_wrapper를 쓰지 않는다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["tool", "protocol", "metaIdTool"],
"properties": {
"tool": { "type": "string", "minLength": 1 },
"protocol": { "enum": ["2025-11-25", "2026-07-28"] },
"metaIdTool": { "type": "boolean" }
}
}
출력 schema $id가 Ajv를 바로 실패시키는 이유는?
2.4.0은 Ajv를 fast-uri 3.1.8과 맞춘다. fast-uri는 $id를 URI로 파싱해 my_tool:output의 콜론을 scheme로 보고 전체가 불법이 된다. my_tool_output이나 $defs+$ref로 바꾼다. structuredContent 층과 무관하지만 unwrap과 같은 CI에서 자주 터진다.
{
"$id": "my_tool:output",
"type": "object",
"additionalProperties": false,
"required": ["region"],
"properties": {
"region": { "type": "string" }
}
}
| 응답과 레코드 | 게이트 반환 | Schema를 적용할 층 |
|---|---|---|
| metaIdTool, protocol 2025-11-25, 루트에 region 등 | validate_root | structuredContent 루트에 출력 schema. |
| 같은 레코드인데 structuredContent는 result만 | legacy_wrapper | structuredContent.result 안에 출력 schema. |
| 감사 레코드 tool 누락 또는 protocol enum 밖 | schema | 감사 JSON을 먼저 고친다. 응답 경로를 추측하지 않는다. |
| 레코드는 유효하지만 body에 structuredContent 없음 | missing | 불완전 응답으로 처리하고 조용히 통과시키지 않는다. |
structured_content_target는 네 분기를 어떻게 고르나?
순서: 감사 schema로 record 검증, body.structuredContent 읽기. metaIdTool이고 2025-11-25에서 content가 result 단일 키면 legacy_wrapper, 아니면 validate_root. metaIdTool false는 항상 validate_root. content가 객체가 아니면 missing.
legacy_wrapper와 validate_root를 passed 하나로 접지 않는다. 보고서에서 따로 세어 업그레이드 후 result 래퍼가 남는 호출을 본다. plain 도구에 legacy 규칙을 쓰면 참양성이 누락된다.
def actual_type(data: object) -> str:
if data is None:
return "null"
if isinstance(data, bool):
return "boolean"
if isinstance(data, list):
return "array"
if isinstance(data, int):
return "integer"
if isinstance(data, float):
return "number"
if isinstance(data, str):
return "string"
if isinstance(data, dict):
return "object"
raise TypeError(type(data))
def validate(data: object, schema: dict) -> list[str]:
errors: list[str] = []
if not isinstance(schema, dict):
return errors
types = schema.get("type")
if isinstance(types, str):
types = [types]
actual = actual_type(data)
if types and not any(t == actual or (t == "number" and actual == "integer") for t in types):
errors.append("type")
if "enum" in schema and not any(value == data for value in schema["enum"]):
errors.append("enum")
if isinstance(data, str) and "minLength" in schema and len(data) < schema["minLength"]:
errors.append("minLength")
if isinstance(data, dict) and schema.get("properties"):
for key in schema.get("required") or []:
if key not in data:
errors.append(f"required:{key}")
if schema.get("additionalProperties") is False:
for key in data:
if key not in schema["properties"]:
errors.append(f"additional:{key}")
for key, sub in schema["properties"].items():
if key in data and data[key] is not None:
errors.extend(validate(data[key], sub))
return errors
def structured_content_target(record: dict, body: dict, schema: dict) -> str:
if validate(record, schema):
return "schema"
content = body.get("structuredContent")
if not isinstance(content, dict):
return "missing"
if record.get("metaIdTool") and record.get("protocol") == "2025-11-25":
if set(content.keys()) == {"result"} and isinstance(content.get("result"), dict):
return "legacy_wrapper"
return "validate_root"
return "validate_root"
JSONNote에서 루트와 result를 어떻게 대조하나?
- 1Schema로 감사 레코드 고정
열기 JSON Schema. 1칸에 감사 schema, 2칸에 tools/call에서 뽑은 record. protocol이나 metaIdTool 오류가 먼저 표시되며, 형태가 깨지면 경로를 고르지 않는다.
- 2포맷으로 structuredContent 층 확인
열기 JSON 포맷, tools/call 결과 전체를 붙인다. structuredContent가 평평한 루트인지 result 단일 키인지 본 뒤 schema 층을 정한다.
- 3Diff로 unwrap 전후 비교
열기 JSON Diff. 왼쪽에 2.4.0 기대 루트, 오른쪽 legacy result. Diff로 강조된 키 경로가 감사 스크립트 수정 줄.
자주 묻는 질문
프로토콜 2026-07-28도 structuredContent를 unwrap하나?
meta id result 래퍼 관점에서 2.4.0 unwrap은 2025-11-25에 묶여 있고 2026-07-28은 이 층을 다시 바꾸지 않았다. 개정을 바꿀 때 SDK 릴리스를 읽되 2026-07-28이 자동으로 다시 unwrap한다고 가정하지 않는다.
2026-07-28 클라이언트가 number 도구의 x-mcp-header를 목록에서 빼면 검증에 영향 있나?
structuredContent Schema 층은 바뀌지 않는다. tools/list 표시층의 방어적 변경으로 number 도구는 x-mcp-header를 클라이언트 열거에 노출하지 않는다. 감사는 tools/call 본문을 schema하고 list annotation으로 unwrap을 추론하지 않는다.
metaIdTool false도 legacy_wrapper인가?
아니다. legacy_wrapper는 meta id 출력이고 protocol 2025-11-25인 도구만 해당한다. 일반 도구는 항상 validate_root; structuredContent가 없거나 객체가 아니면 missing.
호환을 위해 항상 structuredContent.result만 검증할 수 있나?
안 된다. 2025-11-25에서 2.4.0은 루트에 두므로 result만 보면 합법이 실패하고 result 키 소실도 놓친다. 게이트 분기를 쓰거나 legacy_wrapper일 때만 content.result를 읽는다.
Ajv가 $id URI 불법이면 응답을 고치나 schema를 고치나?
고치는 것은 출력 schema 파일이지 tools/call 응답이 아니다. $id 콜론을 밑줄로 바꾸거나 $defs 참조를 쓴다. 응답 unwrap과 $id 파싱은 별개지만 한 CI에서 연속 실패한다.
정리
2.4.0은 2025-11-25 meta id 도구를 structuredContent 루트 직출로 바꿨지만 legacy 서버는 result 래퍼를 줄 수 있다. 감사는 .result 고정이 아니라 분기가 필요하다.
결론: protocol 2025-11-25이고 metaIdTool true면 루트에 필드가 있으면 validate_root. result 단일 키면 legacy_wrapper로 result 내부 검증. 감사 schema 실패면 schema. structuredContent 없으면 missing. 출력 $id에 콜론 금지.
다음: Schema로 감사 레코드를 고정하고, 포맷으로 structuredContent 층을 보고, Diff로 unwrap 전후를 비교하고, 출력 $id를 고친다.