클라이언트 모범 사례
에이전트 같은 MCP 호스트 애플리케이션이 더 많은 MCP 서버에 연결되어 수백 또는 수천 개의 도구에 접근하게 되면 단순한 도구 관리 방식은 한계에 부딪힙니다. 모든 도구 정의를 모델의 컨텍스트 창에 미리 불러오면 토큰을 낭비하고 지연 시간이 늘며 모델 성능도 저하됩니다. 순차적인 도구 호출 사이에서 대규모 중간 결과를 모델을 통해 전달하면 문제가 더 커집니다.
두 가지 패턴으로 이러한 문제를 해결할 수 있습니다. 점진적 탐색은 도구 정의가 컨텍스트에 들어가는 _시점_을 제어하고, 프로그래밍 방식 도구 호출은 도구를 호출하는 _방법_을 제어합니다.
점진적 도구 탐색
단순한 MCP 호스트 구현은 대화를 시작할 때 연결된 모든 서버의 도구 정의를 모델에 직접 전달합니다. 도구가 몇 개뿐이라면 충분히 합리적인 방식입니다. 그러나 수십 개의 서버가 제공하는 수백 개 도구에 호스트가 접근할 수 있다면 모델이 사용자의 메시지를 읽기도 전에 정의만으로 컨텍스트 창의 대부분을 차지할 수 있습니다.
점진적 탐색은 이 문제를 피합니다.
- 호스트는 평소처럼
tools/list로 도구 정의를 가져오되 모델 컨텍스트에 주입하는 시점을 늦춥니다. - 호스트는 모델에 가벼운
search_tools메타 도구를 제공합니다. - 호스트는 필요할 때만 전체 정의를 컨텍스트에 불러옵니다.
점진적 탐색을 사용할 때
점진적 탐색은 도구 정의가 컨텍스트 창의 큰 부분을 차지할 때 가장 적합합니다. 도구 수가 적고 정의가 컨텍스트 창의 작은 부분만 차지한다면 모든 도구를 불러와도 괜찮습니다. 도구 정의가 사용 가능한 컨텍스트 창에서 상당한 비중을 차지하기 시작하면 클라이언트는 점진적 탐색으로 전환해야 합니다. 전환 시점을 판단하기 위한 임계값을 구현할 것을 권장합니다.
- 컨텍스트 창의 비율로 임계값을 구현합니다. 예를 들어 1~5%로 설정할 수 있습니다.
- 도구 정의를 불러오다가 임계값에 도달하면 점진적 탐색으로 전환합니다.
탐색 전략 선택
모델이 search_tools 도구를 호출하면 검색 전략을 선택해야 합니다.
- 키워드 기반: 키워드 일치(BM25, 정규식)를 사용합니다. 특히 설명적인 도구 이름과 설명에 단순하면서 효과적입니다.
- 임베딩 기반: 도구 설명에 벡터 유사도 검색을 적용합니다. 동의어와 의미 기반 일치를 더 잘 처리합니다.
- 서브에이전트 기반: Claude Haiku나 Gemini Flash처럼 작고 빠른 보조 모델이 작업에 사용할 도구를 선택합니다. 일반적으로 매우 잘 작동하지만 임베딩이나 키워드 기반 방식보다 비용이 더 들 수 있습니다.
- 하이브리드: 여러 방식을 결합합니다. 예를 들어 키워드 및 임베딩 순위의 점수를 합치거나 사용 사례와 쿼리에 따라 다른 전략을 선택합니다.
일부 모델 공급자는 이미 내장 도구 검색을 제공합니다. 예를 들어 OpenAI와 Anthropic은 이를 기본 지원합니다. 사용 중인 공급자의 문서에서 같은 기능이 있는지 확인하세요. 기능이 있다면 직접 구현하는 대신 플랫폼의 도구 검색을 사용할 수 있습니다. 공급자가 기능을 제공하지 않거나 도메인별 순위 또는 접근 제어 필터링 같은 특수 검색 로직이 필요하면 직접 구현하세요.
아래의 세 계층 패턴은 사용자 정의 검색 기반 방식을 자세히 보여 주지만, 계층화 원칙(목록, 검사, 실행)은 검색 메커니즘과 관계없이 적용됩니다.
점진적 탐색 사용하기
점진적 탐색의 일반적인 구현 방식은 검색 기반의 세 계층 접근법입니다.
계층 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: 검사. 모델이 후보를 찾으면 해당 도구 하나의 전체 정의(입력 스키마, 출력 스키마, 문서)를 가져옵니다.
// 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: 실행. 모델은 필요한 정의만 불러온 상태에서 인터페이스를 완전히 이해하고 도구를 호출합니다.
이 패턴은 토큰 사용량을 크게 줄이고 도구 선택 정확도를 높일 수 있습니다. 모델이 관련 없는 수백 개 도구를 훑지 않고 관련 도구 몇 개에 집중하기 때문입니다. 다른 탐색 전략(임베딩, 서브에이전트 등)도 같은 계층 원칙을 따르되 목록 계층의 검색 메커니즘만 바꿉니다.
동적 서버 관리
점진적 탐색은 개별 도구를 넘어 서버 전체로 확장할 수 있습니다. 시작할 때 구성된 모든 서버에 연결하는 대신 호스트는 다음과 같이 동작할 수 있습니다.
- 사용 가능한 서버와 상위 수준 설명의 레지스트리를 유지합니다.
- 모델이 해당 서버의 기능이 필요하다고 판단할 때만 연결합니다.
- 현재 작업과 더 이상 관련 없는 서버의 연결을 끊어 컨텍스트를 확보합니다.
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 힌트가 포함됩니다. 명세의 캐싱 유틸리티에 정의된 대로 따르세요. 특히 TTL이 만료되기 전이라도 list_changed 알림이 오면 캐시된 목록을 오래된 것으로 처리해야 합니다.
프롬프트 캐싱과의 상호작용
대부분의 공급자는 tools 배열을 포함한 프롬프트 접두사를 캐시합니다. 대화 도중 도구 정의를 추가하거나 제거하면 해당 캐시가 무효화되고, 그 결과 발생하는 캐시 미스가 제거한 정의보다 더 많은 토큰 비용을 유발할 수 있습니다. 캐시를 유지하려면 다음을 따르세요.
- 새로 발견한 정의를
tools배열을 다시 정렬하지 말고 캐시 중단점 뒤에 추가하거나, 모든 호출을 하나의 안정적인call_tool({name, args})메타 도구로 보내 배열이 바뀌지 않게 합니다. - 서버 연결 해제를 턴마다 수행하지 말고 대화 경계에서 수행합니다.
- 위의 도구 검색 링크와 함께 공급자의 캐싱 문서를 참고합니다.
프로그래밍 방식 도구 호출 / 코드 모드
직접 도구 호출에서는 모든 호출이 한 번의 왕복입니다. 모델이 도구 호출을 생성하고 클라이언트가 실행하면 전체 결과가 모델 컨텍스트로 다시 들어옵니다. 여러 도구를 연결해야 하는 작업(문서 읽기, 변환, 다른 곳에 쓰기)에서는 각 중간 결과가 모델을 통과하며, 모델과 무관한 데이터라도 토큰을 소비하고 지연 시간을 더합니다.
프로그래밍 방식 도구 호출("코드 모드"라고도 함)은 클라이언트가 도구 호출을 조합하는 효과적인 방법을 제공합니다. 도구를 직접 호출하는 대신 모델이 도구를 호출하는 코드를 작성합니다. 코드는 샌드박스 환경에서 실행되고 최종 결과만 모델로 돌아옵니다.
프로그래밍 방식 도구 호출은 강력하며 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 지원과 함께 코드 모드 개념을 라이브러리로 통합합니다. |
| 기타(Wasm 사용) | Wasmtime | Rust / C / Go | 모든 언어를 Wasm으로 컴파일하고 기능 기반 보안으로 실행합니다. |
샌드박스와 관계없이 통합 패턴은 같습니다. 호스트가 함수 스텁을 주입하고 프로세스 내부 또는 stdio 채널을 통해 호출을 가로채며(따라서 네트워크 권한은 완전히 거부된 상태로 유지 가능), MCP 서버에 tools/call 요청으로 전달합니다.
실행 아키텍처
구현은 세 구성 요소로 이루어집니다.
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를 사용할 수 있게 해야 합니다. 처리되지 않은 오류로 스크립트가 종료되면 모델이 스스로 수정할 수 있도록 해당 오류를 스크립트 결과로 표시하세요. 이미 반영된 부분적 부작용은 모델이 보고해야 합니다.
두 패턴 결합하기
점진적 탐색과 프로그래밍 방식 도구 호출은 함께 사용할 때 효과적입니다. 모델은 탐색 도구로 필요한 도구를 찾고 스키마를 불러온 뒤, 한 번의 실행에서 여러 도구를 호출하는 단일 스크립트를 작성합니다. 이 조합은 도구 정의의 토큰 비용과 도구 결과의 토큰 비용을 모두 최소화하여 모델의 컨텍스트가 데이터 전달이 아닌 추론에 집중하게 합니다.