클라이언트 모범 사례

클라이언트 모범 사례 (Client Best Practices)

MCP 호스트 애플리케이션을 많은 서버와 도구에 걸쳐 확장하는 패턴을 다루는 문서예요. 에이전트 같은 MCP 호스트가 더 많은 MCP 서버에 연결하고 수백·수천 개의 도구에 접근하게 되면, 도구를 단순하게 관리하는 방식은 한계에 부딪혀요.

출처: 문서

본문

MCP 호스트 애플리케이션(에이전트 같은)이 더 많은 MCP 서버에 연결하고 수백·수천 개의 도구에 접근하게 되면, 도구를 단순하게 관리하는 방식은 무너집니다. 모든 도구 정의를 처음부터 모델의 컨텍스트 윈도우에 로드하는 것은 토큰을 낭비하고, 지연 시간을 늘리며, 모델 성능을 떨어뜨려요. 그리고 순차 도구 호출 사이에 큰 중간 결과물을 모델을 통해 전달하면 문제가 더 커집니다.

두 가지 패턴이 이런 문제를 해결해요. 점진적 발견(progressive discovery) 은 도구 정의가 언제 컨텍스트에 들어가는지를 제어하고, 프로그래매틱 도구 호출(programmatic tool calling) 은 도구가 어떻게 호출되는지를 제어합니다.

점진적 도구 발견 (Progressive Tool Discovery)

단순한 MCP 호스트 구현은 각 대화 시작 시 연결된 모든 서버의 도구 정의를 모델에 직접 전달해요. 도구가 몇 개뿐이면 이것은 전적으로 합리적입니다. 하지만 호스트가 수십 개의 도구를 노출하는 수십 개의 서버에 접근할 수 있다면, 그 정의만으로도 모델이 사용자 메시지를 읽기 전에 컨텍스트 윈도우 대부분을 차지할 수 있어요.

점진적 발견은 이를 피합니다:

  • 호스트는 평소처럼 tools/list로 도구 정의를 가져오지만, 모델 컨텍스트에 주입하는 것은 미뤄요.
  • 호스트는 모델에 가벼운 search_tools 메타 도구를 제공해요.
  • 호스트는 필요할 때만 전체 정의를 컨텍스트에 로드해요.

점진적 발견을 언제 사용할까요?

도구 정의가 컨텍스트 윈도우의 큰 부분을 차지할 때 점진적 발견을 사용하는 것이 좋아요. 도구 정의가 컨텍스트 윈도우의 작은 부분을 차지하는 소수의 도구 집합이라면 모든 도구를 로드해도 괜찮습니다. 도구 정의가 사용 가능한 컨텍스트 윈도우의 상당 부분을 차지하게 되면, 클라이언트는 점진적 발견으로 전환해야 해요. 클라이언트가 언제 전환할지 결정하는 임계값을 구현할 것을 권장합니다.

  • 임계값을 컨텍스트 윈도우의 백분율로 구현하세요. 예: 1%~5%.
  • 도구 정의를 로드하세요. 임계값에 도달하면 점진적 발견으로 전환하세요.

발견 전략 선택하기

모델이 search_tools 도구를 호출하면 검색 전략을 선택해야 해요.

  • 키워드 기반: 키워드 매칭(BM25, 정규식). 특히 설명적인 도구 이름과 설명에 단순하고 효과적이에요.
  • 임베딩 기반: 도구 설명에 대한 벡터 유사도 검색. 동의어와 의미론적 매칭을 더 잘 처리해요.
  • 서브에이전트 기반: 보조 모델(보통 Claude Haiku나 Gemini Flash 같은 작고 빠른 모델)이 작업에 필요한 도구를 선택해요. 이것은 보통 매우 잘 동작하지만 임베딩 기반이나 키워드 기반보다 비용이 더 들 수 있어요.
  • 하이브리드: 접근 방식을 결합해요. 예를 들어 키워드와 임베딩 순위를 함께 점수를 매기거나, 사용 사례나 쿼리에 따라 다른 전략을 선택하는 식으로요.

일부 모델 제공자는 이미 내장 도구 검색을 제공해요. 예를 들어 OpenAI와 Anthropic은 이를 기본으로 지원합니다. 제공자 문서에서 동등한 기능을 확인하세요. 사용 가능하면 플랫폼의 도구 검색을 커스텀 구현보다 선호할 수 있어요. 제공자가 제공하지 않거나 특화된 검색 로직(도메인별 순위나 접근 제어 필터 등)이 필요할 때 직접 구축하세요.

아래의 3계층 패턴은 커스텀 검색 기반 접근을 자세히 보여 주지만, 계층 원칙(카탈로그, 검사, 실행)은 검색 메커니즘과 무관하게 적용됩니다.

점진적 발견 사용하기

점진적 발견의 일반적인 구현 중 하나는 검색 기반 3계층 접근이에요.

계층 1: 카탈로그. 호스트는 사용 가능한 기능을 검색하기 위한 작은 메타 도구 집합을 노출해요. search_tools 도구는 자연어 쿼리를 받아 일치하는 도구 이름과 간단한 설명을 반환합니다.

// The model calls a lightweight search tool
search_tools({ query: "update salesforce record" })

// Returns concise matches: names and one-line descriptions only
→ [
    { name: "salesforce_updateRecord", description: "Update fields on a Salesforce object" },
    { name: "salesforce_upsertRecord", description: "Insert or update based on external ID" }
  ]

계층 2: 검사(Inspect). 모델이 후보를 식별하면, 그 도구에 대해서만 전체 정의(입력 스키마, 출력 스키마, 문서)를 가져와요.

// The model inspects only the tool it needs
get_tool_details({ name: "salesforce_updateRecord" });

이것은 단일 도구의 완전한 스키마를 반환해요.

{
  "name": "salesforce_updateRecord",
  "description": "Updates a record in Salesforce",
  "inputSchema": {
    "type": "object",
    "properties": {
      "objectType": {
        "type": "string",
        "description": "Salesforce object type"
      },
      "recordId": { "type": "string", "description": "Record ID to update" },
      "data": { "type": "object", "description": "Fields to update" }
    },
    "required": ["objectType", "recordId", "data"]
  }
}

계층 3: 실행(Execute). 모델은 필요한 정의만 로드한 상태에서 인터페이스를 완전히 알고 도구를 호출해요.

이 패턴은 토큰 사용을 극적으로 줄이고 도구 선택 정확도를 높일 수 있어요. 모델이 수백 개의 관련 없는 도구를 훑는 대신 소수의 관련 도구에 집중하기 때문이죠. 다른 발견 전략(임베딩, 서브에이전트 등)도 같은 계층 원칙을 따르지만 카탈로그 계층에서 다른 검색 메커니즘을 대체합니다.

동적 서버 관리

점진적 발견은 개별 도구를 넘어 전체 서버로 확장됩니다. 시작 시 모든 구성된 서버에 연결하는 대신, 호스트는 다음을 할 수 있어요.

  1. 사용 가능한 서버와 그 상위 수준 설명의 레지스트리를 유지한다.
  2. 모델이 그 서버의 기능이 필요하다고 판단할 때만 연결한다.
  3. 현재 작업과 더 이상 관련이 없는 서버를 연결 해제해 컨텍스트를 확보한다.
sequenceDiagram
    participant Model
    participant Host
    participant Registry
    participant Server

    Model->>Host: search_available_servers("CRM")
    Host->>Registry: Query available servers
    Registry-->>Host: Salesforce server (not connected)
    Host-->>Model: Salesforce server available

    Model->>Host: enable_server("salesforce")
    Host->>Server: server/discover
    Server-->>Host: Supported versions + capabilities
    Host->>Server: tools/list
    Server-->>Host: Tool definitions
    Host-->>Model: Salesforce server connected

    Note over Model: Task complete

    Model->>Host: disable_server("salesforce")
    Host-->>Model: Server disconnected, context freed

이것은 특히 범용 에이전트에서 잘 동작하는데, 사용자 의도가 처음에 알려지지 않기 때문이에요. 에이전트는 최소한의 항상 켜진 서버 집합으로 시작하고 필요할 때 다른 서버를 연결합니다. 에이전트 스킬과 결합하면, 스킬 파일이 필요한 MCP 서버를 선언할 수 있고 호스트는 해당 스킬이 호출될 때만 서버를 연결합니다.

구현 지침

점진적 발견을 구현할 때:

지침 근거
여러 상세 수준 제공 모델이 이름만, 이름+설명, 또는 전체 스키마 응답 중 선택하게 하세요.
도구 정의 캐시 서버에서 가져온 후 정의를 호스트 측에서 메모해, 나중에 다시 주입할 때 tools/list 왕복이 필요 없게 하세요. 이것은 현재 모델 컨텍스트에 있는 것과는 별개예요.
list_changed에서 갱신 서버가 notifications/tools/list_changed를 보내면 검색 카탈로그를 다시 인덱싱하세요.
서버별로 도구 그룹화 도구를 원본 서버별로 정리해서 제시하세요. 그러면 모델이 관련 기능을 추론할 수 있어요.

캐싱

각 목록 결과(tools/list 같은)와 각 server/discover, resources/read 결과는 ttlMs와 cacheScope 힌트를 담아요. 사양의 캐싱 유틸리티에 정의된 대로 따르세요. 특히, list_changed 알림이 도착하면 TTL이 만료되기 전이라도 캐시된 목록을 오래된 것으로 취급하세요.

프롬프트 캐싱과의 상호작용

대부분의 제공자는 도구 배열을 포함한 프롬프트 접두사를 캐시해요. 대화 중간에 도구 정의를 추가하거나 제거하면 그 캐시가 무효화되고, 결과적인 미스는 제거한 정의보다 더 많은 토큰 비용이 들 수 있어요. 캐싱을 유지하려면:

  • 새로 발견된 정의를 재정렬 대신 캐시 중단점 이후에 추가하거나, 모든 호출을 단일한 안정된 call_tool({name, args}) 메타 도구로 라우팅해 배열이 절대 바뀌지 않게 하세요.
  • 서버 연결 해제는 턴 단위 작업이 아니라 대화 경계 작업으로 취급하세요.
  • 제공자의 캐싱 문서를 위의 도구 검색 링크와 함께 참고하세요.

프로그래매틱 도구 호출 / 코드 모드 (Programmatic Tool Calling / Code Mode)

직접 도구 호출에서는 모든 도구 호출이 왕복이에요. 모델이 도구 호출을 생성하고, 클라이언트가 실행하며, 전체 결과가 모델 컨텍스트로 다시 흘러들어갑니다. 작업이 여러 도구를 연결해야 할 때(문서 읽기, 변환, 다른 곳에 쓰기), 각 중간 결과는 모델을 통과해, 모델이 그 결과와 아무 관련 없을 때조차 토큰을 소비하고 지연을 추가해요.

프로그래매틱 도구 호출(때로 "코드 모드"라고도 함)은 클라이언트가 도구 호출을 효과적으로 구성할 수 있는 방식을 제공합니다. 도구를 직접 호출하는 대신, 모델이 도구를 호출하는 코드를 작성해요. 코드는 샌드박스 환경에서 실행되고, 최종 결과만 모델로 돌아가요.

프로그래매틱 도구 호출은 강력하고 MCP 도구와 리소스를 더 효율적으로 사용하게 해 주지만, 클라이언트가 샌드박스 환경을 구현해야 해요.

동작 방식

호스트는 MCP 도구 스키마를 샌드박스 안에서 사용 가능한 타입 API로 변환해요. 모델이 도구가 필요하면 스크립트를 작성해 실행합니다.

1단계: MCP 스키마에서 프로그래매틱 API 생성. 호스트는 각 서버의 도구 정의를 읽고, 각 도구의 인수와 outputSchema를 기반으로 타입 함수를 생성합니다.

// Auto-generated from the Logging MCP server's tool schema
interface LogEntry {
  timestamp: string;
  message: string;
  level: string;
}

function logging_getLogs(input: {
  level: "error" | "warn" | "info";
  since: number;
}): Promise<{ entries: LogEntry[] }> {
  return mcp.callTool<{ entries: LogEntry[] }>("logging_getLogs", input);
}

// Auto-generated from the Ticketing MCP server's tool schema
function ticketing_createIssue(input: {
  title: string;
  body?: string;
  priority: "low" | "medium" | "high";
}): Promise<{ issueId: string }> {
  return mcp.callTool<{ issueId: string }>("ticketing_createIssue", input);
}

MCP 서버는 각 도구에 선택적 outputSchema를 제공할 수 있어요. 출력 스키마가 있으면 호스트는 정밀한 반환 타입(위의 LogEntry처럼)을 만들 수 있어요.

출력 스키마가 없을 때는 단순한 경로를 선호하세요:

  • 일반 타입을 사용하고 진행하세요. any나 string을 받아들이고 구조화되지 않은 출력을 하류에서 처리하세요. 진짜 해결책은 서버 작성자가 outputSchema를 제공하는 것입니다.
  • 빠른 모델로 타입 결과를 추출하세요. 루프 밖의 단발 호출에 사용하세요. MCP 도구 호출과 같은 스텁 인터셉션 경로로 호스트가 중개하는 extract(value, ExpectedType) 헬퍼를 노출해서 샌드박스 자체는 결코 네트워크 연결을 열지 않게 하세요. 헬퍼는 작은 모델(예: Claude Haiku나 Gemini Flash)로 라우팅해 값을 ExpectedType으로 강제 변환해요. 이는 호출당 지연을 추가하고 환각을 일으키거나 필드를 놓칠 수 있으므로, 사용하기 전에 ExpectedType에 대해 결과를 검증하세요.

2단계: 모델이 이 API에 대해 코드를 작성합니다. 서로 다른 도구 호출 사이에 전체 결과가 컨텍스트를 통해 흐르는 대신, 모델이 단일 스크립트를 작성해요. "지난 한 시간의 모든 오류 로그를 찾고 각 고유 오류에 티켓을 등록해" 같은 작업을 생각해 보세요. 직접 도구 호출로는 수천 개의 로그 항목이 모델 컨텍스트를 통과할 것입니다. 코드로는 모델이 샌드박스에서 필터링합니다:

// Model-generated code, executes in sandbox
const logs = await logging_getLogs({
  level: "error",
  since: Date.now() - 3600000,
});

// Filter and deduplicate inside the sandbox, not in the model's context
const uniqueErrors = new Map<string, LogEntry>();
for (const log of logs.entries) {
  if (!uniqueErrors.has(log.message)) {
    uniqueErrors.set(log.message, log);
  }
}

for (const [message, log] of uniqueErrors) {
  await ticketing_createIssue({
    title: `Error: ${message}`,
    body: `First seen: ${log.timestamp}\nOccurrences: ${
      logs.entries.filter((l) => l.message === message).length
    }`,
    priority: "high",
  });
}

console.log(
  `Filed ${uniqueErrors.size} tickets from ${logs.entries.length} error logs`,
);

3단계: 샌드박스가 코드를 실행합니다. 샌드박스 안의 함수 호출은 인터셉션되어 호스트 브로커를 통해 적절한 MCP 서버로 라우팅돼요. 로그 데이터와 티켓 생성은 서버 사이에서 직접 흐르며 결코 모델 컨텍스트에 들어가지 않아요. console.log 출력, 즉 단일 요약 줄만 모델로 돌아갑니다.

샌드박스 선택하기

올바른 샌드박스는 모델이 작성하게 할 언어, 호스트 애플리케이션의 언어, 필요한 격리 수준에 따라 달라요. 다음 표는 예시 런타임이지 추천이 아니며, 사용 사례에 대한 성숙도를 평가하세요.

샌드박스 언어 런타임 / 라이브러리 호스트 언어 접근 방식
JavaScript Deno, isolated-vm Rust / Node / CLI 세분화된 권한이 있는 V8 기반 런타임. 모든 권한을 비활성화해 완전 잠금이 가능.
Python Monty (실험적) Rust AI 사용 사례를 위해 만들어진 최소 Python 인터프리터. 기본적으로 I/O 없음.
TypeScript pctx (초기 단계) Python / Rust 코드 모드 개념을 라이브러리로 통합하고, 저수준 Rust 지원.
Any (via Wasm) Wasmtime Rust / C / Go 어떤 언어든 Wasm으로 컴파일해 능력 기반 보안으로 실행.

샌드박스와 무관하게 통합 패턴은 동일해요. 호스트가 함수 스텁을 주입하고, 인프로세스나 stdio 채널로 호출을 인터셉트해서(네트워크 권한을 완전히 거부할 수 있도록), 이를 tools/call 요청으로 MCP 서버에 전달합니다.

실행 아키텍처

구현에는 세 가지 구성 요소가 있어요.

flowchart LR
    subgraph Host["MCP Host"]
        A[LLM] -->|writes code| B[Sandbox]
        B -->|function call| C[MCP Client]
        C -->|return value| B
        B -->|console output| A
    end
    C -->|tool call| D[MCP Server A]
    C -->|tool call| E[MCP Server B]
    D -->|result| C
    E -->|result| C

샌드박스는 직접 네트워크 접근 없이 격리된 환경에서 모델 생성 코드를 실행해요. 외부 세계와의 유일한 인터페이스는 생성된 함수 스텁을 통해이며, 호출을 호스트로 라우팅합니다.

호스트는 브로커 역할을 해요. 샌드박스에서 함수 호출을 받아 올바른 MCP 서버로 매핑하고, 도구 호출을 실행하며, 결과를 샌드박스로 반환합니다. 인증 토큰과 자격 증명은 호스트가 보유하며 생성 코드에 결코 노출되지 않아요.

모델은 샌드박스가 반환하는 것, 보통 console.log 문의 출력이나 최종 반환 값을 봅니다. 이렇게 해서 모델(그리고 클라이언트 개발자)이 컨텍스트 윈도우에 들어가는 것을 정밀하게 제어할 수 있어요.

보안 고려 사항

프로그래매틱 도구 호출은 신중한 샌드박싱이 필요한 코드 실행 표면을 도입해요.

  • 호출별 인증: 브로커는 사양상 여전히 MCP 호스트예요. 직접 호출에 적용하는 것과 같은 인간 개입 확인 정책을 샌드박스 유래 호출에도 적용하세요(도구: 보안 참고). 스크립트를 승인한다고 해서 런타임에 스크립트가 만드는 모든 도구 호출에 대한 포괄적 승인이 되지는 않아요. 호스트는 범주형 승인(예: "이 스크립트 실행 동안 ticketing_createIssue 허용")을 부여할 수 있지만, 브로커는 여전히 각 호출을 그 승인에 대해 평가해야 해요.
  • 서버 간 데이터 흐름: 한 서버의 도구 결과는 다른 서버에게는 신뢰할 수 없는 입력이에요. 브로커는 직접 호출과 동일한 입력 검토 정책을 중개 호출에도 적용해야 합니다. 출력 잘림만으로는 유출을 막지 못해요.
  • 네트워크 격리: 샌드박스는 직접 네트워크 접근이 없어야 해요. 모든 외부 통신은 권한 부여와 접근 통제를 시행하는 호스트 브로커를 통해 흐릅니다.
  • 자격 증명 비노출: API 키와 토큰은 호스트가 보유해요. 생성된 코드는 타입 함수를 호출하고, 호스트는 서버로 전달할 때 인증을 추가해요.
  • 리소스 한계: 실행되지 않는 스크립트를 방지하기 위해 샌드박스 실행에 타임아웃과 메모리 한계를 설정하세요.
  • 출력 필터링: 샌드박스 콘솔 출력을 모델에 다시 피드하기 전에 검증하고 자르세요.

오류 처리

MCP 도구 오류는 전송 실패가 아닌 isError: true가 있는 성공 응답으로 도착해요. 생성된 래퍼는 이를 던져진 예외로 변환해서 모델이 작성한 코드가 try/catch를 사용할 수 있게 해야 해요. 처리되지 않은 오류가 스크립트를 종료하면 그것을 스크립트의 결과로 표면화해서 모델이 스스로 수정하게 하세요. 이미 커밋된 부분 부작용을 보고하는 것은 모델의 책임이에요.

두 패턴 결합하기

점진적 발견과 프로그래매틱 도구 호출은 잘 함께 동작해요. 모델은 발견 도구로 필요한 도구를 식별하고, 스키마를 로드한 다음, 한 번의 실행 패스에서 여러 도구를 호출하는 단일 스크립트를 작성합니다. 이 조합은 도구 정의의 토큰 비용과 도구 결과의 토큰 비용을 모두 최소화해서, 모델 컨텍스트가 데이터를 통과시키는 대신 추론에 집중하게 합니다.

더 알아보기 (Learn more)