Claude Code가 osFoundry 클라우드 Room에 작은 앱을 올릴 때 부르는 도구는 이거다. 토큰 없이 tools/list를 날려서 받은 실서버 스키마에서 필요한 부분만 남겼다.
{ "name": "propose_room_snippet_app", "description": "Prepare a Room snippet with source files. It appears in the cloud Room launcher only after its owner reviews and approves the full code in osFoundry.", "inputSchema": { "type": "object", "properties": { "room_id": {"type": "string"}, "name": {"type": "string", "maxLength": 200}, "files": {"type": "object", "additionalProperties": {"type": "string"}}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128} }, "required": ["room_id", "name", "files", "idempotency_key"], "additionalProperties": false }, "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}] } Room 관련 도구는 네 개가 더 있다. read_room, list_room_apps(limit 1~50), read_room_app_status(room_id, instance_id 필수), 그리고 files 대신 repository_id와 version_id를 받는 propose_room_repo_app. 읽기 세 개는 osfoundry:read에 readOnlyHint: true, 제안 두 개는 osfoundry:write에 destructiveHint: true다. Claude Code가 호출 전에 허락을 묻는 건 이 어노테이션 때문이다.
핵심은 동사다. 이 도구는 Room에 아무것도 만들지 않는다. 만드는 건 제안이고, 그걸 Room 탭으로 바꾸는 건 사람이다. 아래에서 스키마가 막아 주는 것과 서버 몫으로 남는 것, 제안 하나의 무게, idempotency_key가 필수인 이유, 사람의 승인을 어떻게 기다릴지를 차례로 본다. 이 맥에는 claude 바이너리가 없어서 클라이언트 쪽은 파이썬으로 대신해서 쟀다.
스키마로 막히는 것, 안 막히는 것
room_proto.py는 위의 실서버 inputSchema(tools-list.json에 저장)를 jsonschema에 그대로 넣어서 호출을 검증한다. 여기에 osFoundry가 스니펫 제안에 거는 규칙 두 개를 얹었다. Room 런타임의 엔트리인 src/App.tsx가 있어야 하고, 모든 경로가 안전한 상대 경로여야 한다.
$ uv run --with jsonschema --with tiktoken python3 room_proto.py == validation == ok PASS no App.tsx REJECT rule: src/App.tsx missing absolute path REJECT rule: unsafe path '/etc/hosts' dot-dot path REJECT rule: unsafe path 'src/../../x.ts' backslash path REJECT rule: unsafe path 'src\\x.ts' name 201 chars REJECT schema: 'nnnn…' is too long key 7 chars REJECT schema: 'abcdefg' is too short extra field REJECT schema: Additional properties are not allowed ('visibility' was unexpected) non-string file REJECT schema: {'a': 1} is not of type 'string' caught by published schema: 4, needs server rule: 4 잘못된 호출의 절반은 JSON Schema를 그냥 통과한다. files의 키는 additionalProperties로는 못 묶는다. propertyNames에 패턴을 쓰면 되긴 하는데, 정규화한 뒤의 ..까지 거르는 정규식은 쓰고 싶지 않다. 그래서 스키마는 "문자열 맵"까지만 선언하고 키 판정은 서버가 한다. 반면 최상위 additionalProperties: false는 제 몫을 한다. 모델이 멋대로 "visibility": "public"을 붙이면 조용히 무시되는 게 아니라 스키마 에러가 난다.
클라이언트를 짠다면 호출 전에 같은 검사를 끼워 두는 게 좋다. 거절된 제안은 왕복 한 번만 버리는 게 아니라, 파일을 다시 쓰는 출력 토큰까지 버린다.
제안 하나는 얼마나 무거운가
예제는 스탠드업 타이머다. src/App.tsx(63줄)와 src/styles.css(14줄) 두 파일.
== size == src/App.tsx 2021 B 63 lines 583 tok src/styles.css 1046 B 14 lines 399 tok source bytes 3067, envelope bytes 3431, escape+frame overhead 11.9% argument tokens (cl100k) 1141 2 files: envelope 4463 B, 1394 tok 6 files: envelope 12919 B, 4078 tok 21 files: envelope 44639 B, 14143 tok tools/list 45368 B for 59 tools; the 5 room tools 3482 B (939 tok) JSON-RPC 봉투가 늘리는 건 원본 대비 11.9%고, 대부분 줄바꿈과 따옴표 이스케이프다. 진짜 비용은 거기가 아니다. files 안의 모든 바이트는 모델 출력이다. Claude Code는 파일을 한 번 쓰고, 그걸 도구 인자로 한 번 더 쓴다. 두 파일짜리 도구면 인자가 약 1,100토큰, 같은 컴포넌트를 복제해서 21파일로 늘리면 14,143토큰이다. 토큰은 tiktoken cl100k_base(OpenAI 토크나이저)로 셌으니 대략치로 보면 된다.
스키마에는 files의 바이트 상한이 없다. 실제 상한은 리뷰하는 사람 쪽에 있다. 승인 카드는 "Review all proposed files (N)" 아래에 모든 파일을 하나씩 스크롤 박스로 전문 표시한다. 21개를 카드에서 읽을 사람은 없다. 그 정도 규모면 propose_room_repo_app이 맞다. 같은 프로젝트의 GitNest 리포지토리에서 고정된 버전을 가리키고, 카드에는 코드를 붙이는 대신 "Review repository code before approving" 링크가 뜬다.
리뷰 게이트가 보장하는 것
도구 설명 그대로, 스니펫이 클라우드 Room 런처에 뜨는 건 오너가 전체 코드를 보고 승인한 뒤뿐이다. Room에서 런처는 앱 탭 바고, 카드에는 "Approval makes this code available in the Room app launcher. Review the files before approving."라고 적혀 있다. Approve and create를 누르면 탭이 들어가고, Decline이면 사라진다. 대기 중인 제안을 Room 멤버가 열 방법은 없으니, 리뷰 전 스니펫이 누군가의 화면에서 돌 일은 없다.
남는 건 재전송 문제고, 이건 클라이언트 책임이다. HTTP 위의 MCP에서는 서버가 처리한 뒤 응답만 날아가는 일이 있다. 그때 새 키로 다시 보내면 앱 하나에 오너 카드가 두 장 생긴다. room_proto.py에 키로 제안을 찾는 인메모리 서버를 두고, 제안 1,000건, 응답 유실 확률 각 5%로 돌려 봤다.
== review gate, 1000 proposals, 5% lost responses == random calls 1074 owner cards 1074 duplicates 74 in launcher before approval 0 content calls 1057 owner cards 1000 duplicates 0 in launcher before approval 0 시도마다 새 UUID를 붙이면 카드 1,074장 중 74장이 중복이다. 오너가 그걸 골라내서 Decline해야 한다. 키를 내용에서 만들면("snip-" + sha256(room_id, name, files)[:32]) 재전송은 같은 키가 되고 중복은 0이다. 파일을 일부러 고치면 키도 바뀌어서 새 카드가 되니 그게 맞다. 스키마의 최소 8자는 "1" 같은 키를 막으려는 것이다. 두 실행 모두 승인 전에 런처에 들어간 건 0건. 이건 서버 몫이지 클라이언트 몫이 아니다.
오너의 승인은 어떻게 기다리나
initialize가 돌려주는 건 "capabilities": {"tools": {"listChanged": false}}뿐이고, "제안이 승인됐다"는 푸시는 없다. 클라이언트가 고를 수 있는 건 셋이다. 사용자한테 승인하면 말해 달라고 하거나, read_room_app_status를(또는 새 인스턴스가 보일 때까지 list_room_apps를) 폴링하거나, resource_type: "room"을 받는 poll_resource_events를 이어서 읽는 것. 토큰 없이는 승인이 거기에 어떤 이벤트를 남기는지 확인하지 못해서, 시뮬레이션은 상태 폴링으로 했다.
입력은 사람의 리뷰 시간이다. 중앙값 6분인 로그정규분포에서 뽑았고, 최대 1시간까지 기다린다. 이건 가정이지 측정이 아니다. 다만 대부분은 몇 분 안에 답하고 가끔 30분 걸리는 사람이 있다는 모양은 현실적이라고 본다.
== polling while the owner reviews (n=2000, median review 6 min, 1 h budget) == review delay p50 357s p95 1816s fixed 5s calls mean 111.8 lag p50 2.4s p95 4.7s gave up 14 fixed 30s calls mean 19.0 lag p50 14.5s p95 28.4s gave up 14 backoff 5s x1.5 cap 120s calls mean 10.8 lag p50 47.0s p95 111.0s gave up 14 backoff 10s x2 cap 300s calls mean 6.1 lag p50 105.2s p95 273.8s gave up 14 5초 간격이면 승인 후 5초 안에 알아채지만 제안 하나에 112번 부른다. 한 번 부를 때마다 도구 출력을 읽는 모델 턴이 하나씩 나간다. 30초 고정이면 19번에 최악 28초 지연. 상한 2분 백오프면 11번에 p95 지연 111초. 2,000건 중 14건은 리뷰에 1시간 넘게 걸렸고, 어느 전략이든 똑같이 포기했다. 대화형 세션이라면 제일 싸고 맞는 답은 0번이다. "Connected → Needs your approval에서 승인하고 알려 줘"로 턴을 끝내고, 돌아오면 list_room_apps를 한 번 부르면 된다. 폴링은 Claude Code가 기다리는 동안 다른 일을 할 때만 의미가 있다.
참고로 실서버 왕복 시간도 쟀다. 일본에 있는 이 맥에서 토큰 없이 각각 10번씩.
$ python3 probe.py initialize HTTP 200 308 B median 680 ms min 358 max 968 tools/list HTTP 200 45368 B median 687 ms min 429 max 1253 tools/call read_room (no token) HTTP 401 337 B median 559 ms min 427 max 847 www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue" 한 번에 0.6초 정도. 5초 루프면 시간의 1할을 네트워크에 쓰는 셈인데, 호출마다 드는 모델 턴이 훨씬 비싸다.
스니펫의 "빌드 상태"는 뭔가
read_room_app_status가 돌려주는 건 런처와 최신 빌드 상태뿐이고, 설정, 파일, 로그, 시크릿은 없다. 스니펫에는 서버 빌드가 없다. Room은 탭을 여는 쪽에서 파일을 컴파일한다. 데스크톱 앱은 Sucrase를 얹은 샌드박스 iframe, 브라우저는 Sandpack 샌드박스다. 리포지토리 경로도 버전 고정이고, 승인 카드에 "This path does not build or deploy a bundle."이라고 나온다. 어느 제안이든 기다릴 대상은 런처에 들어갔느냐다. 빌드 상태는 누군가 탭을 배포한 뒤에 생기고, 배포는 osFoundry에서 사람이 한다.
로컬 Room에는 왜 안 닿나
read_room에는 "Local Rooms are unavailable", initialize에는 일반 규칙으로 "Local-only data is unavailable to cloud clients."가 있다. 로컬 Room은 데스크톱 앱 자체 DB에 있고 동기화되지 않는다. MCP 엔드포인트는 워크스페이스 단위 OAuth 토큰을 가진 클라우드 서비스라서, 특정 노트북 한 대로 가는 길이 없다. 닿게 하려면 데스크톱 앱이 중계를 맡아야 한다. 기기와 프로젝트별로 켜고, 온라인/오프라인 상태를 갖고, 허용 명령을 좁힌 중계다. 도구도 닿는 척하지 않는다. 같은 선이 스니펫 안의 데이터에도 그어져 있다. SDK 저장소는 탭을 쓰는 사람의 기기에 남고, 어떤 읽기 도구도 DB 내용을 돌려주지 않는다.
직접 붙인다면
- 스키마를 공개해도 경로 키는 서버에서 검증한다. JSON Schema로 "문자열 맵"은 써도 "안전한 상대 경로"는 못 쓴다. 이번 테스트에서 잘못된 호출의 절반이 경로 문제였다.
- 멱등 키는 시도가 아니라 내용에서 만든다. 응답이 유실돼도 비용이 0이고, 진짜 수정만 새 카드가 된다.
- 크기 상한은 리뷰하는 사람의 집중력으로 정한다. 카드에 모든 파일을 띄운다면 스니펫은 몇 개로 끝내고, 큰 앱은 고정 버전 리포지토리로 보낸다.
- 사람의 승인을 에이전트가 기본으로 폴링하게 두지 않는다. 턴을 끝내고, 사용자가 돌아오면 상태를 한 번만 읽는다. 백오프는 다른 일이 있을 때만.
- 되읽기는 얇게 유지한다. 식별자와 런처 상태만 주고 설정이나 앱 데이터는 주지 않는다. 그래야 상태 확인이 사람이 저장한 걸 읽는 뒷문이 되지 않는다.
- 닿지 않는 건 도구 설명에 적는다("Local Rooms are unavailable"). 모델이 재시도하지 않고 사용자에게 그대로 전한다.
