osFoundry는 왜 Claude Code의 툴 허용 목록을 CLI 플래그가 아니라 MCP 서버에 둘까
osFoundry 데스크톱 앱은 Claude Code를 에이전트 모드 워커로 띄울 때 아래 MCP 설정을 넘긴다. 포트와 토큰은 내가 Python으로 다시 짠 브리지를 돌렸을 때 나온 값이다. 구조는 src-tauri/src/mcp_bridge.rs와 같다. {"mcpServers": {"osfoundry": { "type": "http", "url": "http://127.0.
osFoundry 데스크톱 앱은 Claude Code를 에이전트 모드 워커로 띄울 때 아래 MCP 설정을 넘긴다. 포트와 토큰은 내가 Python으로 다시 짠 브리지를 돌렸을 때 나온 값이다. 구조는 src-tauri/src/mcp_bridge.rs와 같다.
{"mcpServers": {"osfoundry": {
"type": "http",
"url": "http://127.0.0.1:51377/mcp",
"headers": {"Authorization": "Bearer 8dd5de…"},
"timeout": 120000
}}}
볼 건 네 가지다.
- 호스트는
127.0.0.1이다.0.0.0.0이 아니다. - 포트는 OS가 고른다(
bind("127.0.0.1:0")). - 토큰은 이번 실행 하나만을 위해 만든 64자리 hex다.
- 툴 목록은 어디에도 없다.
같이 넘기는 커맨드라인에도 --allowedTools는 없다.
claude -p "<conversation>" --output-format stream-json --verbose --include-partial-messages \
--permission-mode bypassPermissions --add-dir <folder> \
--mcp-config '<위의 JSON>' --strict-mcp-config
허용 목록이 없는 게 아니라 서버 쪽에 있는 거다. 왜 그런지 정리하고, 직접 돌아가는 최소 버전을 만들어서 재 봤다.
허용 목록을 왜 서버에 두나
에이전트 모드는 Claude Code를 --permission-mode bypassPermissions로 띄운다. 터미널 앞에서 한 단계씩 승인해 줄 사람이 없으니까. build_claude_command의 주석에 허용 목록을 안 넘기는 이유가 적혀 있다. bypassPermissions에서는 Claude Code가 --allowedTools를 무시한다. 넘겨 봐야 장식이다. mcp_bridge.rs 맨 위에도 같은 말이 한 줄로 박혀 있다. "Never trust the client; the server decides."
이건 Claude Code만의 얘기가 아니다. 플래그는 클라이언트한테 주는 지시고, 클라이언트는 모델이 움직이는 코드 그 자체다. 절대 뚫리면 안 되는 체크 하나를 거기 두는 건 맞지 않다. 서버는 모든 tools/call을 받고, 모델이 돌리는 코드도 아니다. 그래서 체크는 서버 몫이다.
osFoundry는 두 군데서 거른다.
-
tools/list는 이번 실행의 목록에 있는 툴만 돌려준다. 그 목록은agent_cli::tool_policy가 먼저 좁혀 둔다. 나머지 툴은 모델한테 존재 자체가 안 보인다. -
tools/call은 실행하기 전에 같은 목록으로 이름을 다시 확인한다. 모델은 본 적 없는 툴 이름도 내뱉을 수 있다. 학습 데이터에서 떠올린 이름일 수도 있고, 읽은 파일에 심어진 프롬프트 인젝션일 수도 있다. 1번은 이걸 못 막고, 2번이 막는다.
CLI는 처음에 뭘 보나
POST는 세 번이고, 매번 새로 연결한다. 서버가 Connection: close로 응답하기 때문이다.
→ initialize ← 200 {"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"osfoundry",…}}
→ notifications/initialized ← 202, Content-Length: 0
→ tools/list ← 200 {"tools":[{"name":"memory_search","description":…,"inputSchema":{…}}]}
바디 없는 202는 괜한 고집이 아니다. Rust 쪽 기록에 따르면 Codex의 MCP 클라이언트(rmcp)는 알림에 {}와 200이 오면 트랜스포트를 내려 버렸다. 그 바람에 실행이 핸드셰이크에서 통째로 죽었다고 한다. 삽질하기 딱 좋은 부분이다. Claude Code는 넘어가 주지만 Codex는 안 넘어간다. 그래서 id가 없는 메시지와 notifications/로 시작하는 메서드에는 전부 바디 없는 202를 준다.
그다음 Claude Code는 툴을 mcp__<서버>__<툴>로 부른다. memory_search는 mcp__osfoundry__memory_search가 된다. 가운데 osfoundry는 설정에 적힌 서버 이름에서 온다.
다시 만들어 보기
이 Mac에는 claude 바이너리가 없다. 그래서 클라이언트는 스크립트로 흉내 냈다. 헤드리스 에이전트가 HTTP MCP 서버에 하는 일을 그대로 따라 한다. --mcp-config를 파싱하고, 핸드셰이크를 하고, 툴을 부른다. 서버는 Rust 브리지의 로직을 Python 표준 라이브러리만으로 옮겼다. bridge.py, fake_cli.py, run_demo.py 세 파일이고, 다 합쳐 230줄 정도다.
핵심은 bridge.py의 이 부분이다.
class Run:
def __init__(self, workspace_id, channel_id, allowed, poll=0.5):
self.workspace_id, self.channel_id = workspace_id, channel_id # bound now, never read from a request
self.allowed = [t for t in allowed if t in REGISTRY]
self.token = secrets.token_hex(32) # 64 hex chars, new every run
...
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
auth = self.headers.get("Authorization", "")
tok = auth[7:].strip() if auth[:7].lower() == "bearer " else ""
if not hmac.compare_digest(tok.encode(), run.token.encode()):
return self.send(401, {"error": "unauthorized"})
req = json.loads(body)
i, method = req.get("id"), req.get("method", "")
if i is None or method.startswith("notifications/"):
return self.send(202)
...
if method == "tools/call":
p = req.get("params") or {}
name = p.get("name") or ""
if name not in run.allowed: # the only gate that counts
return self.send(200, rpc_err(i, -32601, f"tool '{name}' is not available to this agent"))
text = execute_tool(run.workspace_id, run.channel_id, name, p.get("arguments") or {})
return self.send(200, rpc_ok(i, {"content": [{"type": "text", "text": text}]}))
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H) # loopback, OS-assigned port
execute_tool은 orchestrator::execute_tool 자리를 대신한다. 앱에서는 어디서 온 툴 호출이든 이 함수 하나를 거치고, 승인, 플랜 모드, 채널별 오버라이드, 샌드박스가 다 여기서 걸린다. 브리지는 자기만의 실행 경로가 없다. 이 함수까지 보낼지 말지만 정한다.
레지스트리에는 memory_search, execute_command, delete_file 세 개를 넣고, allowed=["memory_search"]로 실행을 시작했다. 가짜 CLI로 네 번 찔러 봤다.
tools seen by the CLI: ['mcp__osfoundry__memory_search']
call 1: memory_search {"query":"budget"}
→ {"result":{"content":[{"type":"text","text":"Q4 budget review: Thu 14:00."}]}}
call 2: execute_command {"cmd":"rm -rf ~/work"}
→ {"error":{"code":-32601,"message":"tool 'execute_command' is not available to this agent"}}
call 3: memory_search {"query":"contract","workspace_id":"ws-client"}
→ {"result":{"content":[{"type":"text","text":"no matching notes"}]}}
call 4 (wrong token): (401, {'error': 'unauthorized'})
audit: [('ws-personal','ch-ops','memory_search',{'query':'budget'}),
('ws-personal','ch-ops','memory_search',{'query':'contract','workspace_id':'ws-client'})]
after the run: connect to 51377 -> refused
call 2가 플래그로는 못 막는 경우다. 모델이 목록에 없던 툴 이름을 대고, 서버가 execute_tool 앞에서 거절한다. audit에 execute_command가 한 번도 안 찍힌 게 증거다.
call 3은 인자로 다른 워크스페이스를 노린다. "Client X contract renews 2027-01-31"이라는 노트는 실제로 있지만 ws-client에만 있다. 브리지는 자기가 고정해 둔 workspace_id를 넘기기 때문에 검색은 ws-personal에서 돌고, 아무것도 안 나온다. Rust 쪽도 workspace_id와 channel_id는 start() 시점부터 SessionCtx에 들어 있고, 요청에서 읽는 곳은 없다.
마지막 줄은 실행이 끝나면 엔드포인트도 사라지는지 확인한 거다. 앱에서는 McpSession의 Drop이 셧다운 신호를 보낸다. panic이 나든 중간에 return하든, 툴을 돌릴 수 있는 리스너가 남지 않는다.
비용은 얼마나 드나
숫자는 전부 run_demo.py로 쟀다. 환경은 Apple M1(8 GB), Python 3.9.6이고, 다른 렌더 작업이 돌고 있어서 load average가 10 안팎이었다. 실제 브리지가 Connection: close로 강제하는 것에 맞춰 요청마다 TCP 연결을 새로 열었다. 시간은 호출 앞뒤를 time.perf_counter()로 감싼 실제 시간이다.
| 단계 | p50 | p95 | n |
|---|---|---|---|
| 브리지 시작(bind + 서버 스레드) | 1.20 ms | 2.69 ms | 200 |
| 핸드셰이크(initialize, 알림, tools/list) | 1.82 ms | 4.98 ms | 200 |
tools/call 허용 |
0.41 ms | 1.06 ms | 1000 |
tools/call 허용 목록에서 거절 |
0.41 ms | 1.08 ms | 1000 |
| 토큰 틀림, 401 | 0.40 ms | 1.09 ms | 1000 |
종료 serve_forever(poll_interval=0.5)
|
500.91 ms | 501.98 ms | 200 |
종료 poll_interval=0.01
|
10.88 ms | 11.07 ms | 200 |
실행마다 브리지를 새로 띄워도 시작과 핸드셰이크를 합쳐 3 ms 정도다. Claude Code 프로세스는 부팅만 해도 이보다 훨씬 오래 걸리니까, 실행별 서버는 사실상 공짜다. 거절하는 비용도 허용과 똑같다. 체크는 리스트 조회 한 번이라 TCP 왕복에 묻혀 버린다.
유일하게 걸린 건 종료였다. Python의 serve_forever는 poll_interval마다 한 번씩만 셧다운 요청을 확인한다. 기본값이 0.5초라서 실행이 끝날 때마다 반 초씩 기다렸다. Rust 버전에는 이 문제가 없다. accept 루프가 accept()와 watch 채널을 tokio::select!로 묶고 있어서 신호가 오면 바로 빠져나온다. Python으로 짤 거면 poll_interval을 줄이거나, 실행 종료 경로에서 소켓을 직접 닫으면 된다.
왜 stdio가 아니라 HTTP인가
Claude Code에 붙이는 MCP 서버는 보통 stdio 프로세스다. osFoundry가 그렇게 안 한 이유는 mcp_bridge.rs에 나와 있다. 툴 실행기는 앱의 AppHandle이 필요한데, 이건 직렬화도 안 되고 다른 프로세스로 넘길 수도 없다. stdio 서버를 따로 붙여 봐야 모든 호출을 앱으로 다시 넘겨주는 프로세스가 하나 늘 뿐이다. Claude Code는 bearer 헤더가 붙은 "type": "http" 서버를 지원하니까, 앱이 자기 안에서 루프백 포트로 MCP를 서빙한다. 대가로 포트와 토큰이 생기고, 그래서 둘 다 실행마다 새로 만든다.
직접 붙여 볼 거라면
- 허용 목록은 서버의
tools/call핸들러에 두고,tools/list도 같은 목록으로 거른다.--allowedTools같은 CLI 플래그는 힌트로만 본다. "bypass" 계열 권한 모드에서는 특히 그렇다. - 워크스페이스, 사용자, 채널은 세션 시작 때 고정한다. 툴 인자나 클라이언트가 쓸 수 있는 헤더에서 가져오지 않는다.
- bearer는 실행마다 새로 만들고 상수 시간으로 비교한다(
hmac.compare_digest). 오래 쓰는 에이전트 토큰을 툴 실행 엔드포인트에 재활용하지 않는다. -
127.0.0.1:0으로 bind해서 포트는 OS한테 맡긴다. 리스너 수명은 자식 프로세스에 묶고, 종료가 정말 바로 끝나는지도 확인한다. - 알림에는 바디 없는 202를 준다. 다른 응답이 오면 연결을 끊는 클라이언트가 있다.
-
--strict-mcp-config(또는 그 CLI에서 같은 역할을 하는 옵션)를 넘겨서, 범위를 좁혀 둔 실행에 사용자 본인의 MCP 서버가 끼어들지 않게 한다.
CLI가 아닌 에이전트에서 osFoundry가 툴 범위를 어떻게 좁히는지는 에이전트별 툴 스코프에 있다. 외부 AI의 키와 구독 로그인을 어떻게 다루는지는 BYOK 페이지에 정리돼 있다.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.