Model Context Protocol 서버에서의 JSON Schema 사용 패턴

mcpjson-schematypescriptzodvalidation
By sko X opus 4.19/19/20255 min read

Model Context Protocol (MCP) 서버는 도구 매개변수 정의, 입력 검증, 클라이언트-서버 간 통신의 타입 안전성 보장을 위해 JSON Schema를 광범위하게 활용합니다. 이 종합 가이드는 프로덕션 서버와 공식 사양에서 가져온 실용적인 구현 패턴을 살펴보며, 개발자가 견고한 MCP 구현을 구축할 수 있는 실행 가능한 인사이트를 제공합니다.

MCP의 TypeScript 우선 스키마 아키텍처

Model Context Protocol은 TypeScript 우선 접근 방식을 채택하여 권위 있는 스키마 정의가 TypeScript 코드에서 시작되고, 더 넓은 호환성을 위해 JSON Schema가 자동으로 생성됩니다. 이러한 설계 철학은 TypeScript 구현에서 타입 안전성을 보장하면서 다른 언어를 위한 범용 JSON Schema 검증을 제공합니다.

공식 MCP 사양에서 JSON Schema는 네 가지 중요한 영역에 나타납니다. 도구는 수락된 매개변수를 정의하기 위해 inputSchema를 사용하고 구조화된 응답을 검증하기 위해 outputSchema(사양 2025-06-18에서 도입)를 사용합니다. 리소스는 템플릿 정의 및 콘텐츠 검증을 위해 스키마를 사용합니다. 프롬프트는 인수 구조와 메시지 형식을 검증합니다. 전체 프로토콜 메시지 구조 자체가 JSON Schema를 사용하여 정의되며, 요청/응답 형식, 알림, 오류 처리, 기능 협상을 다룹니다.

현재 사양에는 주목할 만한 제한 사항이 있습니다: structuredContent는 객체 타입이어야 하므로 JSON Schema의 더 넓은 기능에도 불구하고 도구가 배열이나 원시 타입을 반환하는 것을 방지합니다. 커뮤니티 제안 #834는 완전한 JSON Schema 2020-12 지원을 확립하여 객체 전용 제한을 제거하고 고급 검증 구성을 가능하게 하는 것을 목표로 합니다.

Zod 대 네이티브 JSON Schema 접근 방식

현대 MCP 구현은 우수한 개발자 경험과 자동 타입 추론에 의해 네이티브 JSON Schema보다 스키마 정의에 Zod를 압도적으로 선호합니다. 다음은 실용적인 비교입니다:

// Zod 접근 방식 - TypeScript 환경에서 선호됨
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const weatherSchema = z.object({
  location: z.string().describe("도시 이름 또는 우편번호"),
  units: z.enum(["celsius", "fahrenheit"]).default("celsius"),
  includeForcast: z.boolean().optional()
});

server.registerTool("get_weather", {
  description: "현재 날씨 데이터 가져오기",
  inputSchema: weatherSchema
}, async (args) => {
  // TypeScript가 자동으로 매개변수 타입을 추론함
  // args.location은 string, args.units는 "celsius" | "fahrenheit"
  const validated = weatherSchema.parse(args);
  // 완전한 타입 안전성으로 구현
});

네이티브 JSON Schema 접근 방식은 더 장황하지만 언어에 구애받지 않는 호환성을 제공합니다:

// 네이티브 JSON Schema 접근 방식
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [{
      name: "get_weather",
      description: "현재 날씨 데이터 가져오기",
      inputSchema: {
        type: "object",
        properties: {
          location: {
            type: "string",
            description: "도시 이름 또는 우편번호"
          },
          units: {
            type: "string",
            enum: ["celsius", "fahrenheit"],
            default: "celsius"
          }
        },
        required: ["location"],
        additionalProperties: false
      }
    }]
  };
});

Zod 접근 방식은 자동 TypeScript 타입 추론, 상세한 오류 메시지가 있는 내장 런타임 검증, 뛰어난 IntelliSense 지원, 외부 종속성 없음을 제공합니다. 그러나 TypeScript 환경이 필요하며 학습 곡선이 있습니다. 네이티브 JSON Schema는 프로토콜 네이티브 호환성, 모든 프로그래밍 언어에서 작동, 기존 JSON Schema 도구와의 통합을 제공하지만 타입 안전성이 부족하고 수동 검증 구현이 필요합니다.

MCP 도구의 일반적인 스키마 패턴

프로덕션 MCP 서버는 유연성과 검증 엄격성의 균형을 맞추는 여러 스키마 패턴을 일관되게 사용합니다. 가장 널리 사용되는 패턴은 기본값이 있는 선택적 매개변수를 처리합니다:

const toolSchema = z.object({
  // 검증이 있는 필수 매개변수
  query: z.string().min(1).describe("검색 쿼리"),

  // 기본값과 제약 조건이 있는 선택적
  limit: z.number().min(1).max(100).default(10)
    .describe("반환할 최대 결과 수"),

  // 기본값 없는 선택적
  category: z.string().optional()
    .describe("카테고리별 필터링"),

  // 혼합 요구사항이 있는 중첩 객체
  options: z.object({
    includeMetadata: z.boolean().default(false),
    sortBy: z.enum(["relevance", "date", "popularity"]).default("relevance")
  }).optional()
});

복잡한 중첩 구조의 경우 MCP 도구는 종종 계층적 데이터를 모델링합니다:

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
  country: z.string().default("US")
});

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  addresses: z.array(addressSchema).min(1),
  preferences: z.record(z.string(), z.any()).optional()
});

도구 매개변수 정의를 위한 모범 사례

효과적인 매개변수 정의는 검증, 문서화, 오류 처리에 세심한 주의가 필요합니다. 입력 정화는 보안 취약점을 방지합니다:

const secureInputSchema = z.object({
  filename: z.string()
    .regex(/^[a-zA-Z0-9._-]+$/, "잘못된 파일명 문자")
    .refine(name => !name.startsWith('.'), "숨겨진 파일은 허용되지 않음"),

  sqlQuery: z.string()
    .refine(query => {
      const dangerous = ['drop', 'delete', 'truncate'];
      return !dangerous.some(word =>
        query.toLowerCase().includes(word)
      );
    }, "쿼리에 위험한 작업이 포함됨"),

  userInput: z.string()
    .trim()
    .min(1, "입력은 비어있을 수 없음")
    .max(1000, "입력이 최대 길이를 초과함")
    .transform(input => input.replace(/[<>]/g, ""))
});

풍부한 설명은 LLM 이해를 향상시킵니다:

const documentedSchema = z.object({
  query: z.string()
    .describe("검색 쿼리 - 와일드카드(*)와 따옴표로 묶인 정확한 구문 지원"),

  dateRange: z.object({
    start: z.string().datetime()
      .describe("ISO 8601 형식의 시작 날짜 (YYYY-MM-DDTHH:mm:ssZ)"),
    end: z.string().datetime()
      .describe("ISO 8601 형식의 종료 날짜 (YYYY-MM-DDTHH:mm:ssZ)")
  }).optional()
    .describe("날짜 범위로 결과 필터링 - 두 날짜 모두 제공되어야 함"),

  sortBy: z.enum(["relevance", "date", "popularity"])
    .default("relevance")
    .describe("정렬 순서: relevance (최적 일치), date (최신순), popularity (최다 조회)")
});

MCP의 고급 JSON Schema 기능

기본 스키마가 대부분의 도구에 충분하지만, 복잡한 MCP 서버는 고급 JSON Schema 기능을 활용합니다. 그러나 클라이언트 호환성은 크게 다릅니다.

allOf를 사용한 스키마 구성

allOf 키워드는 주소 도구에서 국가별 검증을 가능하게 합니다:

{
  "type": "object",
  "properties": {
    "street_address": {"type": "string"},
    "country": {"enum": ["US", "Canada", "Netherlands"]}
  },
  "allOf": [
    {
      "if": {"properties": {"country": {"const": "US"}}},
      "then": {"properties": {"postal_code": {"pattern": "[0-9]{5}(-[0-9]{4})?"}}
    },
    {
      "if": {"properties": {"country": {"const": "Canada"}}},
      "then": {"properties": {"postal_code": {"pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]"}}}
    }
  ]
}

anyOf를 사용한 유니온 타입

데이터베이스 연결 도구는 유연한 구성을 위해 anyOf를 사용합니다:

const connectionSchema = z.union([
  z.object({
    type: z.literal("postgresql"),
    host: z.string(),
    port: z.number(),
    database: z.string(),
    username: z.string(),
    password: z.string()
  }),
  z.object({
    type: z.literal("sqlite"),
    file_path: z.string()
  }),
  z.object({
    type: z.literal("connection_string"),
    connection_string: z.string()
  })
### 클라이언트 제한 사항 해결 방법

다른 MCP 클라이언트는 다양한 JSON Schema 지원 수준을 가집니다. Google 에이전트는 `anyOf`지원하며 (`allOf` 또는 `oneOf`는 지원하지 않음), Gemini API는 유니온 타입을 전혀 지원하지 않으며, Cursor는 서버를 40개의 도구와 60자의 조합된 이름으로 제한합니다. 프로덕션 서버는 기능 감지를 구현합니다:

```javascript
function transformSchemaForClient(schema, clientType) {
  const capabilities = {
    "claude-desktop": ["refs", "unions", "formats"],
    "cursor": ["basic"],
    "google-agents": ["anyof-only"],
    "gemini": ["basic", "no-unions"]
  };

  if (!capabilities[clientType].includes("unions")) {
    // anyOf/oneOf 제거, 첫 번째 변형만 사용
    schema = removeUnions(schema);
  }

  if (!capabilities[clientType].includes("refs")) {
    // 모든 $ref 참조 인라인
    schema = inlineReferences(schema);
  }

  return schema;
}

검증 전략 및 구현

MCP 서버는 AJV 같은 전통적인 JSON Schema 검증기보다 런타임 검증을 위한 Zod를 주로 사용합니다. 이 패턴은 공식 SDK와 프로덕션 구현에서 나타납니다:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const UserFilterSchema = z.object({
  userId: z.string().describe("필터링할 사용자 ID"),
  page: z.number().int().positive().optional().default(1),
  limit: z.number().int().positive().optional().default(10)
});

server.registerTool("getUserData", {
  inputSchema: UserFilterSchema
}, async (args) => {
  try {
    const validated = UserFilterSchema.parse(args);
    // 타입 안전성으로 처리
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  } catch (error) {
    if (error instanceof z.ZodError) {
      const details = error.errors.map(e =>
        `${e.path.join('.')}: ${e.message}`
      ).join(', ');

      return {
        content: [{ type: "text", text: `검증 실패: ${details}` }],
        isError: true
      };
    }
    throw error;
  }
});

성능 최적화를 위해 컴파일된 검증기를 캐시합니다:

const validatorCache = new Map<string, z.ZodSchema>();

function getValidator(toolName: string, schema: z.ZodSchema) {
  if (!validatorCache.has(toolName)) {
    validatorCache.set(toolName, schema);
  }
  return validatorCache.get(toolName)!;
}

JSON Schema에서 타입 생성

기존 JSON Schema 사양으로 작업할 때, MCP 서버는 TypeScript 인터페이스 생성을 위해 json-schema-to-typescript를 사용합니다:

import { compile } from 'json-schema-to-typescript';

const schema = {
  title: "MCPToolInput",
  type: "object",
  properties: {
    query: { type: "string" },
    filters: {
      type: "array",
      items: { type: "string" }
    },
    options: {
      type: "object",
      properties: {
        timeout: { type: "number" },
        retry: { type: "boolean" }
      }
    }
  },
  required: ["query"],
  additionalProperties: false
};

const typescript = await compile(schema, 'MCPToolInput', {
  bannerComment: '/* 자동 생성된 MCP 도구 타입 */',
  unreachableDefinitions: true // $refs가 있는 스키마에 중요
});

// 생성됨:
// export interface MCPToolInput {
//   query: string;
//   filters?: string[];
//   options?: {
//     timeout?: number;
//     retry?: boolean;
//   };
// }

양방향 타입 안전성을 위해 타입을 먼저 정의한 다음 스키마를 파생시킵니다:

interface CreateUserRequest {
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest'])
}) satisfies z.ZodType<CreateUserRequest>;

// 컴파일 타임에 타입 호환성 확인
type SchemaType = z.infer<typeof createUserSchema>;
const _typeCheck: SchemaType extends CreateUserRequest ? true : false = true;

JSON Schema 버전 호환성

MCP 사양은 현재 명시적인 JSON Schema 버전 선언이 부족하여 클라이언트 간 검증 불일치를 야기합니다. 다른 구현은 다른 버전을 가정합니다 - 일부는 Draft-07을 사용하고, 다른 일부는 2020-12 지원을 시도합니다. 이러한 버전 모호성은 검증 실패와 런타임 오류를 초래합니다.

커뮤니티 제안 #834는 명시적인 $schema 필드 지원과 함께 JSON Schema 2020-12를 기본 방언으로 설정하는 것을 목표로 합니다. 이것은 고급 검증 구성(anyOf, oneOf, allOf), 더 엄격한 검증을 위한 unevaluatedProperties, 구조화된 콘텐츠의 객체 전용 제한 제거를 가능하게 할 것입니다.

공식 2020-12 지원이 도착할 때까지, 개발자는 모든 클라이언트에서 작동하는 보수적인 스키마 기능을 사용하고, 여러 MCP 클라이언트 구현에 대해 스키마를 테스트하며, 미래 호환성을 위한 마이그레이션 경로를 준비해야 합니다.

오류 처리 및 검증 패턴

MCP는 모든 도구가 따라야 하는 구조화된 오류 응답 형식을 정의합니다:

interface MCPErrorResponse {
  isError: true;
  content: Array<{
    type: "text";
    text: string;
  }>;
}

function handleValidationError(error: ZodError): MCPErrorResponse {
  const errorDetails = error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
    code: err.code
  }));

  const userFriendlyMessage = `${errorDetails.length}개 필드에서 검증 실패:\n${
    errorDetails.map(detail => `- ${detail.field}: ${detail.message}`).join('\n')
  }`;

  return {
    isError: true,
    content: [{
      type: "text",
      text: userFriendlyMessage
    }]
  };
}

프로덕션 복원력을 위해 외부 종속성에 대한 회로 차단기를 구현합니다:

class CircuitBreaker {
  private failures = 0;
  private state: 'closed' | 'open' | 'half-open' = 'closed';

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      throw new Error('서비스를 일시적으로 사용할 수 없음');
    }

    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess() {
    this.failures = 0;
    this.state = 'closed';
  }

  private onFailure() {
    this.failures++;
    if (this.failures >= 3) {
      this.state = 'open';
      setTimeout(() => this.state = 'half-open', 60000);
    }
  }
}

실제 구현 예제

프로덕션 MCP 서버는 정교한 스키마 패턴을 보여줍니다. GitHub MCP 서버는 유니온 타입과 함께 복잡한 중첩 스키마를 사용합니다:

{
  "name": "create_pull_request",
  "inputSchema": {
    "type": "object",
    "properties": {
      "repo": {"type": "string"},
      "title": {"type": "string"},
      "body": {"type": "string"},
      "reviewers": {
        "anyOf": [
          {"type": "array", "items": {"type": "string"}},
          {"type": "null"}
        ]
      }
    },
    "required": ["repo", "title"]
  }
}

Ultimate MCP 서버는 동적 스키마와 함께 다중 제공자 라우팅을 구현합니다:

const multiProviderSchema = z.object({
  prompt: z.string(),
  providers: z.array(z.object({
    provider: z.enum(["google", "anthropic", "gemini", "deepseek"]),
    model: z.string(),
    temperature: z.number().min(0).max(2).optional()
  })).min(1),
  commonParameters: z.record(z.string(), z.any()).optional()
});

개발자 팁과 권장사항

스키마 설계를 위해: 자동 타입 추론을 활용하기 위해 TypeScript 프로젝트에서 Zod로 시작하세요. LLM이 매개변수 목적을 이해할 수 있도록 설명적인 필드 이름과 포괄적인 설명을 사용하세요. 의미 있는 오류 메시지로 적절한 검증을 구현하세요. 적절한 곳에 합리적인 기본값을 제공하세요.

검증을 위해: 더 나은 TypeScript 통합을 위해 AJV보다 Zod를 선호하세요. 포괄적인 오류 보고와 함께 요청 시간 검증을 구현하세요. 성능을 위해 컴파일된 검증기를 캐시하세요. 컨텍스트 인식 검증을 위해 동적 스키마 주입을 사용하세요.

타입 안전성을 위해: 스키마를 먼저 정의한 다음 TypeScript 타입을 파생시키세요. 양방향 타입 호환성을 보장하기 위해 satisfies 연산자를 사용하세요. 빌드 파이프라인에 타입 생성을 통합하세요. 컴파일 타임 안전성과 함께 런타임 타입 확인을 구현하세요.

오류 처리를 위해: 항상 isError 플래그와 함께 구조화된 오류 응답을 반환하세요. 명확하고 실행 가능한 오류 메시지를 제공하세요. 외부 종속성에 대한 회로 차단기를 구현하세요. 부분적인 실패를 우아하게 처리하세요. 클라이언트 메시지를 정화하면서 서버 측에서 전체 오류 세부 정보를 기록하세요.

프로덕션 배포를 위해: 여러 MCP 클라이언트에 대해 스키마를 테스트하세요. 클라이언트 기능 감지를 구현하세요. 제한된 클라이언트를 위한 대체 스키마를 제공하세요. 검증 실패율을 모니터링하세요. 스키마 요구사항을 명확하게 문서화하세요.

Model Context Protocol의 JSON Schema 통합은 타입 안전하고 검증된 도구 인터페이스를 구축하기 위한 견고한 기반을 제공합니다. 버전 호환성과 클라이언트 지원에 대한 현재 제한 사항이 과제를 제시하지만, 생태계는 빠르게 성숙해지고 있습니다. 이러한 패턴과 모범 사례를 따르면 개발자는 강력하고 복원력 있는 MCP 서버를 만들 수 있으며, 프로토콜의 미래 개선 사항과 함께 발전할 준비가 되어 있습니다.