인터페이스 설계부터 등록, 라우팅, 권한, 실행, 스트리밍까지 — 도구 생명주기의 모든 것.
Claude Code가 모델에 노출하는 모든 능력 — 파일 읽기, Bash 명령 실행, 웹 검색, MCP 서버 호출 — 은 도구(Tool)로 추상화됩니다. 도구 시스템은 AI의 추론과 머신의 구체적인 사이드 이펙트 사이의 다리 역할을 합니다. 모델이 "이 파일을 읽어야 한다"고 결정하면, 도구 시스템이 그 의도를 검증하고, 권한을 확인하고, 실행하고, 결과를 직렬화하여 다시 모델에 전달합니다.
Tool.ts, tools.ts, tools/utils.ts, services/tools/toolOrchestration.ts, services/tools/toolExecution.ts, services/tools/StreamingToolExecutor.ts
도구 시스템의 핵심 설계 원칙:
도구의 정의부터 결과 반환까지 8단계 생명주기를 보여주는 종합 플로우차트입니다.
이 파이프라인의 각 단계는 단일 책임을 가지며, 검증 실패 시 후속 단계로 진행하지 않는 단계적 방어(defense in depth) 패턴을 따릅니다.
Tool.ts)Tool<Input, Output, P> 타입은 도구 시스템의 프로토콜 계약입니다. 클래스 계층이 아닌 구조적 타입으로 정의되어 있어, 인터페이스를 만족하는 어떤 객체든 도구로 사용할 수 있습니다.
세 개의 제네릭 매개변수를 받습니다:
Input — Zod 스키마로 정의되는 입력 타입. strictObject를 사용하여 선언되지 않은 필드를 자동 거부Output — 도구 실행 결과의 타입P — 진행 이벤트(progress event)의 형태. 장시간 실행 도구가 중간 상태를 보고할 때 사용// Tool.ts — 도구 프로토콜의 핵심 타입 정의
export type Tool<Input extends AnyObject, Output, P extends ToolProgressData> = {
name: string
aliases?: string[]
inputSchema: Input
maxResultSizeChars: number
call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>
checkPermissions(input, context): Promise<PermissionResult>
isConcurrencySafe(input): boolean
isReadOnly(input): boolean
isDestructive?(input): boolean
}
name / aliases — 도구의 기본 이름과 대체 이름. 모델이 도구를 호출할 때 이 이름으로 라우팅됨inputSchema — Zod 스키마. 모델의 JSON 출력을 파싱하고 검증. strictObject로 선언되어 예상 외 필드를 거부maxResultSizeChars — 결과 문자열의 최대 크기. 초과 시 잘라내어 컨텍스트 윈도우 낭비를 방지call() — 실제 도구 로직. 검증과 권한 확인을 통과한 후에만 호출됨checkPermissions() — 실행 전 권한 검사. PermissionResult를 반환하여 허용/거부를 판단isConcurrencySafe(input) — 입력값에 따라 이 특정 호출이 병렬 실행에 안전한지 판단. 도구 타입이 아닌 호출별 결정isReadOnly(input) — 이 호출이 순수 읽기 작업인지 여부isDestructive?(input) — 선택적. 이 호출이 되돌리기 어려운 파괴적 작업인지 여부buildTool() 팩토리buildTool()은 ToolDef(부분 정의)를 받아 안전한 실패-폐쇄 기본값으로 병합합니다. 도구 작성자가 명시적으로 선언하지 않은 속성은 가장 보수적인 값으로 설정됩니다:
isConcurrencySafe 기본값: false — 상태 변경을 가정하여 직렬 실행을 강제isReadOnly 기본값: false — 쓰기 작업으로 가정하여 더 엄격한 권한 확인을 적용tool surface)과 내부 도구 객체내부의 Tool 객체가 그대로 모델에 노출되는 것은 아닙니다. 등록 단계에서 실제 실행용 메서드와 권한 로직을 가진 내부 객체를, 모델이 볼 수 있는 도구 표면, 즉 이름, 설명, 입력 스키마 중심의 API 정의로 변환합니다. 이 분리가 중요한 이유는 checkPermissions(), isConcurrencySafe(), contextModifier 같은 내부 실행 세부사항은 모델에게 노출할 대상이 아니기 때문입니다.
contextModifiercontextModifier는 도구가 전역 상태를 직접 mutate하지 않고, 실행 결과와 함께 후속 컨텍스트 변경 함수를 돌려주는 패턴입니다. 오케스트레이터가 이 함수를 수집한 뒤 안전한 시점에 적용하므로, 도구 구현은 실행과 상태 반영을 분리할 수 있습니다. 이 때문에 동시 실행 배치 안에서는 즉시 적용되지 않으며, 배치가 끝난 뒤 순서대로 반영됩니다.
클래스 계층(AbstractTool → ReadTool, BashTool, ...)은 공유 행위를 super 호출 체인으로 강제합니다. Claude Code의 도구 시스템은 대신 프로토콜을 사용합니다: 필요한 메서드와 속성을 가진 어떤 객체든 도구가 됩니다. 이 설계는 테스트에서 목(mock) 도구를 쉽게 만들 수 있고, MCP 프록시 도구처럼 외부 프로토콜에서 생성된 도구도 동일한 파이프라인을 탈 수 있게 합니다.
tools.ts)도구 등록은 3단계 조립 파이프라인으로 구성됩니다:
getAllBaseTools() — 모든 내장 도구의 원시 정의를 수집합니다. 각 도구 파일(tools/bash.ts, tools/read.ts 등)에서 buildTool()로 생성된 도구 객체를 가져옵니다.getTools() — 기능 플래그(feature() 호출), 환경, 연결된 MCP 클라이언트를 반영해 실제 세션에서 쓸 도구 집합을 구성합니다. 이 단계에서 모델에 보여줄 도구 표면도 함께 준비됩니다.assembleToolPool() — 최종 도구 배열을 Anthropic API에 전송할 형태로 조립합니다. 이때 서버 측 프롬프트 캐시 브레이크포인트 보존을 위해 내장 도구와 MCP 도구를 별도의 알파벳 그룹으로 정렬합니다. 이후 권한의 deny 규칙이 적용되면, 일부 도구 표면은 모델에게 아예 숨겨질 수 있습니다.// tools.ts — 캐시 안정 정렬
export function assembleToolPool(builtInTools, mcpTools) {
const sortedBuiltIn = [...builtInTools].sort((a, b) => a.name.localeCompare(b.name))
const sortedMcp = [...mcpTools].sort((a, b) => a.name.localeCompare(b.name))
// 두 그룹을 별도 정렬 — 교차 배치하면 프롬프트 캐시 브레이크포인트 무효화
return [...sortedBuiltIn, ...sortedMcp]
}
Anthropic API는 시스템 프롬프트와 도구 정의의 접두사가 이전 요청과 동일하면 프롬프트 캐시를 재사용합니다. 도구 순서가 요청마다 달라지면 캐시가 무효화되어 수만 토큰의 재처리가 발생합니다.
내장 도구는 세션 내내 고정이지만, MCP 도구는 서버 연결 상태에 따라 동적으로 추가/제거될 수 있습니다. 두 그룹을 분리하면 MCP 도구가 변경되어도 내장 도구 접두사의 캐시는 유지됩니다. 내장 도구 그룹 내에서는 알파벳순으로 정렬하여 결정적 순서를 보장합니다.
기능 플래그로 게이팅된 도구가 세션 중간에 활성화/비활성화되면 도구 목록이 변경되어 프롬프트 캐시가 무효화됩니다. 이 때문에 대부분의 도구 게이팅은 세션 시작 시 한 번만 평가되고, 세션 중간에는 변경되지 않도록 설계되어 있습니다.
toolOrchestration.ts)모델이 한 번의 응답에서 여러 도구를 호출할 때, 오케스트레이터가 실행 순서와 동시성을 결정합니다. 핵심은 파티셔닝 알고리즘입니다.
isConcurrencySafe(input) === true인 연속된 도구들을 하나의 배치로 묶습니다.isConcurrencySafe(input) === false인 도구를 만나면 현재 배치를 중단하고, 해당 도구를 단독 직렬 실행합니다.예를 들어, 모델이 [Read, Read, Bash, Read, Read, Read]를 호출하면:
[Read, Read] — 병렬 실행[Bash] — 직렬 실행 (배치 1 완료 후)[Read, Read, Read] — 병렬 실행 (Bash 완료 후)환경 변수 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY로 한 배치 내 최대 동시 실행 수를 제어합니다. 기본값은 10입니다. 배치에 15개 도구가 있으면 10개를 먼저 실행하고, 슬롯이 비면 나머지 5개를 실행합니다.
contextModifier 적용 시점contextModifier를 적용합니다. 다음 도구가 업데이트된 컨텍스트를 사용할 수 있습니다.contextModifier를 일괄 적용합니다. 배치 내 도구 간에는 컨텍스트 변경이 보이지 않습니다.isConcurrencySafe(input)이 도구 타입이 아닌 입력값을 받는 이유는, 같은 도구라도 입력에 따라 안전성이 달라질 수 있기 때문입니다. 예를 들어, Bash 도구에서 cat file.txt(읽기)는 동시 실행에 안전하지만, rm -rf /(쓰기)는 안전하지 않습니다. 이 설계를 통해 읽기 전용 Bash 호출은 다른 읽기 도구와 병렬로 실행되어 전체 처리 시간을 단축합니다.
StreamingToolExecutor.ts)일반적인 도구 실행은 API 응답이 완전히 도착한 후 시작됩니다. StreamingToolExecutor는 이를 최적화하여 API에서 블록이 스트리밍되는 동안 도구 실행을 시작합니다.
각 도구 호출은 네 가지 상태를 순차적으로 거칩니다:
queued — 도구 호출 블록이 감지되었지만 아직 입력이 완전하지 않음executing — 입력 파싱 완료, call() 실행 중completed — 실행 완료, 결과 대기 중yielded — 결과가 모델 요청 순서대로 방출됨동시성 비안전(isConcurrencySafe === false) 도구는 현재 실행 중인 모든 도구가 완료될 때까지 대기한 후 단독 실행됩니다. 스트리밍 환경에서도 오케스트레이션의 직렬 보장이 유지됩니다.
Bash 도구에서 오류가 발생하면 같은 배치의 형제 도구 실행을 중단합니다. 반면 Read, WebFetch 등의 도구 오류는 형제 도구에 영향을 주지 않습니다.
Bash 명령은 암묵적 의존성 체인을 가질 수 있습니다. 모델이 mkdir -p build && cd build && cmake ..과 cat build/config.h를 동시에 요청하면, 첫 번째 명령이 실패할 경우 두 번째 명령도 의미가 없습니다. 반면 Read("file_a.txt")와 Read("file_b.txt")는 완전히 독립적이어서 하나가 실패해도 다른 하나의 결과는 여전히 유효합니다.
// StreamingToolExecutor.ts — 도구 생명주기 상태
type ToolLifecycleState = 'queued' | 'executing' | 'completed' | 'yielded'
// 비안전 도구는 실행 중인 모든 도구 완료 대기
if (!tool.isConcurrencySafe(input)) {
await waitForAllExecuting()
}
// Bash 오류만 형제 도구 중단 전파
if (tool.name === 'Bash' && result.isError) {
abortSiblings()
}
결과는 실행 완료 순서가 아닌 모델 요청 순서대로 방출됩니다. 도구 B가 도구 A보다 먼저 완료되어도, A의 결과가 먼저 yielded 상태로 전환됩니다. 이는 모델이 도구 결과를 요청 순서와 매칭하여 해석하기 때문입니다.
toolExecution.ts)checkPermissionsAndCallTool()는 개별 도구 호출의 전체 실행 흐름을 관리합니다. 각 단계는 이전 단계가 성공해야 다음으로 진행되는 심층 방어(defense in depth) 패턴을 따릅니다.
inputSchema의 strictObject로 입력을 파싱. 선언되지 않은 필드는 자동 거부backfillObservableInput() — 로깅과 디버깅을 위해 관찰 가능한 입력 형태를 채움. 원본의 복제본에서 작업하여 직렬화된 트랜스크립트를 보호canUseTool() — 사용자 권한 확인. 대화형 모드에서는 사용자에게 승인을 요청할 수 있음tool.call() — 실제 도구 로직 실행maxResultSizeChars에 맞게 잘라냄// toolExecution.ts — 심층 방어
if ('_simulatedSedEdit' in validatedInput) {
delete validatedInput._simulatedSedEdit
// Zod strictObject가 이미 거부해야 하지만 추가 제거
}
_simulatedSedEdit모델이 가끔 Bash 도구 입력에 _simulatedSedEdit 같은 내부 필드를 주입하려는 시도가 관찰되었습니다. Zod의 strictObject가 이미 이런 필드를 거부해야 하지만, toolExecution.ts는 실행 전에 이 필드를 추가로 명시적 제거합니다. 이것이 심층 방어의 전형적인 예입니다 — 한 레이어가 실패해도 다른 레이어가 보호합니다.
backfillObservableInput()이 복제본에서 작업하는 이유원본 입력 객체를 변경하면 직렬화된 트랜스크립트가 변경됩니다. 이는 테스트의 VCR(Video Cassette Recorder) 픽스처 해시를 깨뜨립니다. VCR 테스트는 API 요청/응답을 녹화하고 재생하여 결정적 테스트를 가능하게 하는데, 입력 객체의 변경이 해시에 영향을 주면 픽스처가 무효화되어 모든 관련 테스트가 실패합니다. 복제본에서 작업하면 원본의 불변성이 보장됩니다.
도구 실행 파이프라인 전체에 걸쳐 PermissionContext 객체가 흘러다니며, 각 도구 호출의 권한 판단을 중앙 집중적으로 관리합니다. 이 컨텍스트는 현재 권한 모드, 승인된 규칙 목록, 거부 규칙 목록을 포함하며, 파이프라인의 각 단계에서 참조됩니다. 중요한 점은 권한이 두 번 작동한다는 것입니다. 먼저 도구 표면을 모델에게 노출할지 결정하고, 그다음 실제 호출 시 입력까지 포함해 다시 검사합니다.
isAllowed / isDenied 체크 흐름권한 판단은 두 단계로 진행됩니다:
isDenied() 우선 체크: 거부 규칙이 허용 규칙보다 우선합니다. 거부 목록에 매칭되면 어떤 허용 규칙이 있더라도 즉시 거부됩니다.isAllowed() 체크: 거부되지 않은 경우, 승인된 도구 목록과 규칙 패턴을 순회하며 허용 여부를 판단합니다. 매칭되는 허용 규칙이 없으면 사용자에게 권한 프롬프트를 표시합니다.filterToolsByDenyRules()도구 목록이 모델에 전달되기 전에 filterToolsByDenyRules()가 거부 규칙에 해당하는 도구를 사전에 제거합니다. 이는 모델이 사용할 수 없는 도구를 아예 보지 못하게 하여, 불필요한 도구 호출 시도와 권한 거부 오류를 방지합니다.
// PermissionContext — 도구 파이프라인을 관통하는 권한 컨텍스트
type PermissionContext = {
permissionMode: PermissionMode
approvedRules: PermissionRule[]
denyRules: DenyRule[]
}
// 거부 규칙으로 도구 목록 사전 필터링
function filterToolsByDenyRules(
tools: Tool[],
context: PermissionContext
): Tool[] {
return tools.filter(tool => {
// 거부 규칙에 매칭되는 도구를 제거
const denied = context.denyRules.some(
rule => matchesToolName(rule, tool.name)
)
return !denied
})
}
// 개별 도구 호출 시 권한 판단 흐름
function checkPermission(tool, input, context) {
// 1단계: 거부 규칙 우선 — 매칭 시 즉시 거부
if (isDenied(tool, input, context.denyRules)) {
return { result: 'denied' }
}
// 2단계: 허용 규칙 체크 — 매칭 시 자동 허용
if (isAllowed(tool, input, context.approvedRules)) {
return { result: 'allowed' }
}
// 매칭 없음 — 사용자에게 권한 프롬프트 표시
return { result: 'ask' }
}
이 설계의 핵심은 거부 우선(deny-first) 원칙입니다. 관리자가 설정한 거부 규칙은 사용자의 세션 승인보다 항상 우선하며, filterToolsByDenyRules()를 통해 모델이 거부된 도구를 인지조차 하지 못하게 합니다. 다만 도구가 노출되었다고 해서 실행이 자동 허용되는 것은 아니며, 실제 호출 단계의 checkPermissions()가 더 구체적인 입력 기준으로 다시 게이트를 겁니다.
Tool은 구조적 타입으로 정의됩니다. buildTool()이 실패-폐쇄 원칙에 따라 안전한 기본값을 채워 넣습니다getAllBaseTools() → getTools() → assembleToolPool() 파이프라인이 내장/MCP 도구를 별도 알파벳 그룹으로 정렬하여 프롬프트 캐시를 보존합니다isConcurrencySafe(input)은 도구 타입별이 아닌 도구 호출별로 판단됩니다. 같은 Bash 도구라도 읽기 명령과 쓰기 명령은 다르게 취급됩니다contextModifier를 통한 함수적 변형. 도구는 전역 상태를 직접 변경하지 않고, contextModifier 함수를 반환하여 오케스트레이터가 적절한 시점에 적용합니다_simulatedSedEdit) 등 추가적인 안전 메커니즘이 적용됩니다Q1. buildTool()이 isConcurrencySafe의 기본값을 false로 설정하는 이유는?
Q2. assembleToolPool()에서 내장 도구와 MCP 도구를 별도 알파벳 그룹으로 정렬하는 이유는?
Q3. StreamingToolExecutor에서 Bash 도구 오류만 형제 도구를 중단시키는 이유는?
Read나 WebFetch는 서로 독립적이어서 하나의 실패가 다른 호출의 유효성에 영향을 주지 않습니다.Q4. backfillObservableInput()이 복제본에서 작업하는 목적은?
Q5. 도구가 동시 배치에서 실행된 경우 contextModifier는 언제 적용되나?
contextModifier를 일괄 적용합니다. 이는 배치 내 도구 간 컨텍스트 변경의 비결정성을 방지하면서도 배치 완료 후 후속 도구에 업데이트된 컨텍스트를 제공합니다.