블로그 • 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 형태이지 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 래퍼다.

2.4.0 기대: structuredContent 루트에 필드.
{
  "structuredContent": {
    "region": "apac",
    "count": 42
  }
}
legacy: result 단일 키가 남을 수 있다.
{
  "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를 먼저 반환한다.

감사 레코드: protocol과 metaIdTool이 unwrap 규칙을 정한다.
{
  "tool": "get_report",
  "protocol": "2025-11-25",
  "metaIdTool": true
}

아래 schema로 감사 레코드 형태를 고정한다. metaIdTool false 일반 도구도 validate_root지만 structuredContent에 필드가 없으면 missing으로 처리하고 legacy_wrapper를 쓰지 않는다.

감사 schema. 형태 통과 후 validate_root 또는 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에서 자주 터진다.

fast-uri가 거부하는 $id 예.
{
  "$id": "my_tool:output",
  "type": "object",
  "additionalProperties": false,
  "required": ["region"],
  "properties": {
    "region": { "type": "string" }
  }
}
응답과 레코드게이트 반환Schema를 적용할 층
metaIdTool, protocol 2025-11-25, 루트에 region 등validate_rootstructuredContent 루트에 출력 schema.
같은 레코드인데 structuredContent는 result만legacy_wrapperstructuredContent.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 규칙을 쓰면 참양성이 누락된다.

게이트: schema, legacy_wrapper, validate_root, missing.
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를 어떻게 대조하나?

  1. 1
    Schema로 감사 레코드 고정

    열기 JSON Schema. 1칸에 감사 schema, 2칸에 tools/call에서 뽑은 record. protocol이나 metaIdTool 오류가 먼저 표시되며, 형태가 깨지면 경로를 고르지 않는다.

  2. 2
    포맷으로 structuredContent 층 확인

    열기 JSON 포맷, tools/call 결과 전체를 붙인다. structuredContent가 평평한 루트인지 result 단일 키인지 본 뒤 schema 층을 정한다.

  3. 3
    Diff로 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를 고친다.

블로그로 돌아가기