MCP 파사드(Facade): curl 없이 에이전트가 백엔드와 통신하는 방법
요약
Docker 샌드박스의 네트워크 격리 환경에서 에이전트가 백엔드와 통신할 때 발생하는 문제를 MCP(Model Context Protocol)의 구조적 특성을 통해 해결하는 과정을 다룹니다. MCP 클라이언트가 샌드박스 내부가 아닌 호스트 측에서 실행된다는 점을 발견하여 불필요한 네트워크 터널링 없이 문제를 해결합니다.
핵심 포인트
- network: none 설정된 샌드박스 내 curl 통신 실패 원인 분석
- Unix 소켓 바인드 마운트를 통한 네트워크 우회 시도(옵션 C)
- MCP 클라이언트가 호스트 측에서 실행된다는 핵심 구조 발견
- MCP 구조를 활용한 네트워크 격리 환경에서의 효율적인 도구 호출
에이전트 스킬(agent skill)이 Docker 샌드박스 내부에서 curl http://127.0.0.1:7200/api/v1/notes를 실행합니다. 인증이 실행되기도 전에 종료 코드 7 — "연결할 수 없음(couldn't connect)" — 이 발생하며 실패합니다. 샌드박스가 network: none 설정으로 실행되었기 때문입니다. 도달할 수 있는 루프백(loopback)도 없고, 네트워크 자체가 아예 존재하지 않습니다.
그동안 SKILL.md 파일들은 모델에게 애초에 샌드박스 내부에는 존재하지도 않는 $CLAW_API_KEY 환경 변수를 사용하여 인증하라고 자신 있게 지시하고 있었습니다.
결국 상황은 이랬습니다. 우리 에이전트들은 도달할 수 없는 백엔드에, 존재하지 않는 토큰을 사용하여 통신하도록 지시받은 상태였습니다. 노트에 쓰는 모든 작업, 모든 캘린더 조회, 모든 "이것을 기억해줘"라는 명령 — 이 모든 것이 벽을 향해 curl을 날리는 것과 같았습니다. 이 포스트는 해당 모델을 대체한 MCP 파사드(facade)와, 전체 설계를 깔끔하게 무너뜨린 발견에 대해 다룹니다.
# 에이전트의 Docker 샌드박스 내부 (network: none)
$ curl http://127.0.0.1:7200/api/v1/notes
# 종료 코드 7 — 인증이 실행되기도 전에 연결할 수 없음
우리가 거의 구축할 뻔했던 해결책
첫 번째 본능은 벽을 허물지 않고 그 벽에 구멍을 뚫는 것이었습니다. network: none을 완전히 유지하면서 — 격리는 사고가 아니라 기능이니까요 — 샌드박스에 네트워크 주소 대신 파일 시스템 소켓(filesystem socket)을 전달하는 방식입니다. Unix 소켓을 컨테이너에 바인드 마운트(Bind-mount)하면, 에이전트는 curl --unix-socket /run/claw.sock http://core/api/v1/notes를 수행할 수 있습니다. TCP도 없고, 네트워크 네임스페이스(network namespace)도 필요 없으며, 그저 컨테이너에 마운트된 파일 디스크립터(file descriptor)만 있으면 됩니다. 이를 옵션 C라고 부릅시다.
옵션 C는 타당했습니다. 바인드 마운트된 소켓은 종료 코드 7 curl을 죽게 만들었던 network: none이라는 벽을 실제로 우회할 수 있습니다. Unix 소켓은 네트워크가 아니라 파일이기 때문입니다. 작동했을 것입니다.
그러다 우리는 이 모든 것을 무의미하게 만든 무언가를 발견했습니다.
모든 것을 바꾼 발견
Model Context Protocol (MCP)에는 클라이언트(client)와 서버(server)가 있습니다. 우리는 실제로 도구 호출(tool call)을 수행하는 주체인 MCP 클라이언트가 에이전트의 코드가 있는 곳, 즉 샌드박스 내부에 존재할 것이라고 가정해 왔습니다.
그렇지 않습니다. MCP 클라이언트는 에이전트의 Docker 컨테이너 내부가 아니라, OpenClaw 런타임 프로세스 내의 **호스트 측 (host-side)**에서 실행됩니다. 모델이 도구 호출 (tool call)을 생성하면, 샌드박스 외부의 런타임이 이를 전달합니다.
이 단 하나의 사실이 문제의 정의를 완전히 바꿉니다. 도구 호출은 샌드박스 내부에서 시작되지 않기 때문에, 샌드박스의 네트워크 네임스페이스 (network namespace)에 전혀 닿지 않습니다. curl을 종료 코드 7로 실패하게 만들었던 바로 그 벽인 network: none 설정은 경로상에 존재하지 않습니다. 터널링할 것도, 바인드 마운트 (bind-mount)할 소켓도, 구축할 브리지 (bridge)도 없습니다.
옵션 C는 하룻밤 사이에 쓸모없게 되었습니다. 옵션 C는 네트워크 없이 Core에 도달한다는 실제적인 문제를 해결할 수 있었겠지만, 호스트 측 MCP가 별도의 리스너 (listener)를 실행하고 보안을 유지할 필요 없이 동일한 문제를 더 깔끔하게 해결했기 때문입니다. 우리는 코드를 한 줄도 쓰기 전에 이를 보류했습니다.
이제 구조는 명확해졌습니다. Core는 자체적인 MCP 서버 — 즉 파사드 (facade) — 를 실행하고, API의 각 도메인을 타입이 지정된 도구 (typed tool)로 노출합니다.
Facade: core/src/mcp/claw-os-mcp-server.ts
127.0.0.1:7250에서 실행되는 streamable-HTTP MCP 서버
(Core 자체는 127.0.0.1:7200에서 실행됨)
각 도메인은 action 열거형 (enum)을 가진 claw_<domain>이라는 이름의 도구 하나를 할당받습니다. claw_notes는 action: create | list | get | update | delete를 받습니다. claw_calendar, claw_contacts, claw_finance 및 나머지 도구들도 동일한 형식을 따릅니다. 모델은 URL을 배우지 않습니다. 대신 타입이 지정된 도구들의 어휘를 학습합니다.
두 번째 백엔드가 아닌, 번역기
가장 중요했던 결정이자 우리가 계속해서 되돌아왔던 결정은 바로 이것입니다: 파사드는 재구현 (reimplementation)이 아니라 얇은 번역기 (thin translator)여야 한다는 것입니다.
이 프로젝트의 유혹적인 버전은 두 번째 백엔드를 만드는 것입니다. 즉, 노트를 생성하고, 범위를 확인하고, 승인을 실행하고, 감사 기록 (audit record)에 서명하는 방법을 아는 서비스를 만드는 것이죠. 그런 방식은 재앙입니다. 그것은 실제 Core의 복제본일 뿐이며, 누군가 검증 규칙 (validation rule)을 변경하고 복사본을 잊어버리는 순간 즉시 동기화가 어긋나게 됩니다.
우리는 그런 방식을 거부했습니다. 모든 파사드 도구 호출은 정확히 세 가지 일만 수행합니다:
- 기능 레지스트리 (capability registry) 조회 →
(method, url, body, query). Authorization: Bearer <auth_token>와x-claw-capability: <domain>.<action>를 포함하는 내부 (internal) HTTP 요청 생성.app.inject()를 통해 **전체 Fastify 파이프라인 (full Fastify pipeline)**으로 해당 요청을 전달:auth → scope-validation → semantic-scope → approval-check → handler → audit.
app.inject()는 Fastify의 인프로세스 요청 주입기 (in-process request injector)입니다. 이는 실제 HTTP 서버가 실행하는 모든 플러그인과 미들웨어를 소켓 없이 통과하여 요청을 실행합니다. 따라서 claw_notes 호출과 사용자의 POST /api/v1/notes 호출은 _동일한 복도 (same hallway)_를 지나게 됩니다. 파사드 (facade)는 Bearer 접두사를 추가하고, 기능을 명시한 뒤, 즉시 역할을 마칩니다.
// 에이전트가 실제로 호출하는 것 (파사드 도구, :7250 포트의 호스트 측)
claw_notes {
"action": "create",
...
// 파사드가 내부적으로 생성하여 app.inject()를 통해 전달하는 것
Authorization: Bearer <auth_token> // 파사드가 "Bearer"를 추가함
x-claw-capability: notes.create
...
모든 호출이 실제 파이프라인을 통해 라우팅되기 때문에, 파이프라인이 이미 강제하고 있는 모든 사항은 아무것도 재작성되지 않은 채 그대로 유지됩니다:
| 무료로 유지되는 항목 | 위치 |
|---|---|
| 기본 거부 스코프 (Deny-by-default scopes) | scope-validation.ts |
| ... |
에이전트의 신원은 auth_token 인자에 담겨 전달됩니다. auth.ts는 이를 통해 고정된 userId를 확인하며, 이것이 사칭 (impersonation)을 불가능하게 만드는 핵심입니다. 모델은 필드를 수정함으로써 다른 사람인 척할 수 없습니다. 토큰 자체가 곧 신원이기 때문입니다.
기능 레지스트리 (The capability registry)
1단계의 (method, url, body, query) 조회는 선언적 레지스트리 (declarative registry)에서 이루어집니다. 이는 core/src/mcp/registry/domains/*.ts 하위에 도메인당 하나의 파일로 존재합니다. 각 Capability는 (domain, action, params) 삼중항 (triple)을 정확히 하나의 내부 HTTP 요청에 매핑합니다.
// 레지스트리 항목: (domain, action, params) -> 하나의 내부 요청
// core/src/mcp/registry/domains/notes.ts
{
domain: 'notes',
action: 'create',
method: 'POST',
url: '/notes',
...
}
이 레지스트리(registry)는 단일 진실 공급원(single source of truth)이며, 두 명의 소비자에게 동시에 데이터를 공급함으로써 그 가치를 증명합니다. 동일한 선언을 통해 다음 항목들이 생성됩니다:
- 파사드(facade)의 도구 카탈로그 (모델이 호출 가능한 도구로 인식하는 것).
- 동적 도구 액세스(Tool Access)를 구동하는
GET /managed-agents/available-tools엔드포인트 (관리자가 대시보드에서 에이전트별로 토글하는 기능).
파일 하나로 두 개의 접점(surface)을 관리하므로, 두 정보가 불일치할 가능성이 없습니다. 새로운 기능(capability)을 추가하면 동일한 커밋 내에서 모델의 어휘(vocabulary)와 권한 UI 모두에 즉시 반영됩니다.
보안이 최우선이어야 했습니다
이 설계 전체에는 숨겨진 불편한 진실이 있습니다. 에이전트들이 network: none 뒤에 머물러 있는 동안에는 많은 잠재적 취약점들이 문제가 되지 않았습니다. 모든 것을 차단하는 벽에 의해 격리되어 있었기 때문입니다. 하지만 우리가 에이전트를 호스트 측(host-side)에 연결하는 순간 그 벽이 허물어졌고, 해당 취약점들은 실시간 위협이 되었습니다.
따라서 이 프로젝트의 실제 첫 번째 단계(Fase 0)는 파사드가 아니었습니다. 그것은 무언가를 노출하기 전에 세 가지 구멍을 막는 것이었습니다:
| 가드(Guard) | 차단한 내용 |
|---|---|
| C1 | keyScope === 'agent'일 때 POST/PATCH /agent-tokens가 이제 403을 반환합니다. 즉, 에이전트는 자신의 토큰을 직접 발행하거나 권한을 확장할 수 없습니다 (자기 권한 상승 방지). |
| ... |
C3는 시스템 전체의 태세를 변화시키는 요소입니다. 이전에는 "이 리소스를 매핑하지 않았다"는 것이 "무엇이든 허용된다"는 의미였습니다. 하지만 이제는 "안 된다"는 의미가 됩니다. 민감한 도메인들은 이 바닥(floor) 위에서 의도적이고 신중하게 다시 노출되어야 했습니다. 순서가 잘못될 경우 조용히 보안 구멍을 배포하게 되는 바로 그런 작업입니다.
신원(Identity): 모델이 토큰을 복사하는 것을 중단하다
신원(Identity): 모델이 토큰을 복사하는 것을 중단하다
최초의 인증 모델(ADR-10)은 모델에게 자체 auth_token—64자리 헥스 문자열로 된 비밀 값—을 모든 claw_* 호출에 포함하도록 책임을 부여했습니다. 정상적인 환경에서는 이것이 작동합니다. 하지만 구조적으로 취약합니다. 모델이 잘못된 도구 호출을 즉흥적으로 수행하는 순간, 토큰도 손상되거나 환각(hallucinates)을 일으키고, 더 심각하게는 하위 에이전트가 아예 토큰을 받지 못했습니다. sessions_spawn을 통해 생성된 자식 에이전트는 토큰을 주입하는 채팅 브릿지를 거치지 않았기 때문에 위임된 작업이 조용히 실패했습니다. 이것이
우리가 초기에 세운 한 가지 규칙이 있습니다. API를 설명하기 위해 기능 레지스트리 (capability registry)를 절대 그대로 복제하지 말라는 것입니다. 실제 REST 서피스 (REST surface)를 감사 (Audit) 하세요. 레지스트리는 그것의 의도적인 _부분 집합 (subset)_일 뿐이며, 그렇지 않은 척하는 것은 노출된 내용을 숨기는 행위이기 때문입니다.
그래서 2026-07-03에 우리는 두 가지를 교차 감사했습니다:
| 서피스 (Surface) | 개수 |
|---|---|
routes/ 내의 REST 엔드포인트 (endpoints) | 1456 |
| ... |
약 60%의 차이는 의도적인 것이지, 미처리된 작업 (backlog)이 아닙니다. 이는 에이전트가 건드릴 필요가 없는 관리, 설정, 바이너리 및 UI 전용 엔드포인트들입니다. 감사의 가치는 바로 이 격차를 명시적으로 만드는 데 있습니다. 짧은 리스트가 긴 리스트를 대변한다고 믿는 대신, 엔드포인트 하나하나를 측정하여 실제 서피스가 무엇인지 파악해야만 에이전트가 무엇을 할 수 있는지 추론할 수 있습니다.
우리에게 비용을 치르게 한 주의할 점들 (The gotchas)
타입이 지정된 도구 (typed tools)를 모델에 연결하는 작업은 설계와는 무관하고 배관 (plumbing) 문제와 직결된 날카로운 모서리(sharp edges)가 있다는 것이 밝혀졌습니다.
MCP SDK는 선언되지 않은 인자 (args)를 조용히 제거합니다. 도구의 inputSchema에 선언되지 않은 모든 인자는 파사드 (facade)에 도달하기 전에 삭제됩니다. ID 훅 (identity hook)이 호스트 측에서 auth_token (및 _conversation_id)을 _주입 (injects)_하기 때문에, 해당 필드들은 반드시 스키마에 선언되어 있어야 합니다. 그렇지 않으면 SDK가 훅이 주입한 값을 제거해 버려 전달 과정에서 조용히 소멸됩니다. 주입하려는 것은 반드시 선언된 것이어야 합니다.
z.record 스키마가 약한 모델을 84회의 호출 루프에 빠뜨렸습니다. 우리의 첫 번째 버전은 도구의 params를 z.record → JSON Schema properties: {} (선언된 필드 없음)로 출력했습니다. 작은 로컬 모델 (IQ2_M 수준의 35B-A3B인 Francis)에게 "a로 시작하는 연락처"를 요청하자, 스키마가 채울 수 있는 정보를 아무것도 제공하지 않았기 때문에 모델은 params: { search: "a" }를 구성하지 못하고 params: {} 상태로 84번의 호출을 수행했습니다. 우리는 tool-builder.ts에서 모든 액션의 .shape, .partial().passthrough()의 합집합으로 params를 생성함으로써 이를 해결했습니다. 즉, 모델이 추론할 수 있는 실제 이름이 지정된 필드들을 제공한 것입니다.
단 하나의 .email()이 모든 Groq 채팅을 중단시켰습니다. zod v4의 .email()은 전방 탐색 (lookahead)이 포함된 정규 표현식 (regex)을 생성합니다. Groq은 모든 도구 스키마 (tool schema)를 RE2 엔진으로 컴파일하는데, RE2는 전방 탐색 (lookarounds)을 지원하지 않습니다. 그 결과, files 도메인을 포함하고 있던 모든 에이전트에서 400 에러가 반환되며 전체 채팅이 거부되었습니다. 필드 하나, 전방 탐색 하나 때문에 대화 전체가 마비된 것입니다. 이는 tool-builder.ts에 일반적인 전방 탐색 제거 새니타이저 (sanitizer)를 도입하여 해결했습니다 (실제 검증은 여전히 원본 zod를 대상으로 수행되므로 보안이 약화되지는 않습니다).
그리고 프리셋 (preset) 주의사항: 에이전트에게 파사드 (facade)를 전달하는 도구 키 (tool key)인 bundle-mcp는 반드시 tools.sandbox.tools.alsoAllow에 포함되어 있어야 합니다. 그렇지 않으면 샌드박스 (sandbox)가 이를 제거하여 에이전트가 파사드 도구들을 전혀 볼 수 없게 됩니다. 코딩, 메시징 및 전체 프리셋에 이 설정이 포함되어 있는 바로 그 이유 때문입니다.
완전성을 위해, 몇몇 스킬 (skills)은 의도적으로 마이그레이션되지 않고 여전히 curl을 사용합니다: claw-pdf-tools (바이너리 엔드포인트), claw-bind (/api/v1/auth 하위에 존재하며 JWT/시스템 토큰 사용), 그리고 네 가지 claw-app-* 메타 스킬 (앱 저작, 관리자 흐름).
내가 다르게 했을 일
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기