Claude 채팅에서 osFoundry Graph를 띄우는 툴을 공개 MCP 엔드포인트의 tools/list로 뽑아 봤다. 2026-11-08에 인증 없이 받아서 필요한 부분만 남겼다.

{"name": "run_graph_session", "description": "Queue a Graph run on the owner's online desktop in a connected project. Retrieve progress and result with get_graph_run. The desktop can go offline, and local tool approvals still apply.", "inputSchema": { "properties": { "graph_session_id": {"type": "string"}, "arguments": {"type": "object"}, "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}, "required": ["graph_session_id", "idempotency_key"], "additionalProperties": false}, "annotations": {"readOnlyHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} 

짝꿍 툴이 셋 있다. list_graph_sessions(project_id, limit 1~50)와 get_graph_run(command_id)은 osfoundry:read 읽기 전용이고, cancel_graph_run(command_id)은 시작 툴과 같은 osfoundry:run이다. 연결된 계정으로 실제로 호출해 본 응답도 붙여 둔다. ID는 지웠다.

list_graph_sessions {"project_id": "…"} → {"project_id": "…", "sessions": []} get_graph_run {"command_id": "<unknown>"} → error: Graph run not found or access removed 

빈 목록이 기본값이다. 오너가 데스크톱에서 세션을 공유하기 전까지는 아무것도 안 뜬다. 그리고 설명의 동사가 "Queue"다. 이 툴은 Graph가 시작되기도 전에 돌아온다. 왜 그렇게 만들었는지, 그게 호출하는 에이전트한테 뭘 요구하는지를 데스크톱 앱 코드와 Python으로 다시 짠 시뮬레이션으로 따라가 봤다.

Graph 세션이 뭐고, 왜 서버에서 못 돌리나

Graph는 데스크톱의 fleet 엔진이 돌리는 저장된 JavaScript 프로그램이다. 조건 분기, 에이전트 병렬 실행, 횟수 제한 있는 재시도, 다른 Graph 호출까지 들어간다. apps/web/src-tauri/src/graph_config.rs의 run_graph_now가 Graph 화면에서 쓰는 Tauri 커맨드인데, 스크립트를 run_fleet_script에 넘기고 토큰 상한에 닿으면 다음 에이전트 웨이브를 거부한다. Graph 세션은 기기 하나에 저장된 Graph 하나의 고정 ID다. gs_ + 16진수 32자고, 로컬 graph_sessions 테이블에 있다.

엔진도, 스크립트가 띄우는 에이전트도, 로컬 모델도, 폴더도, 툴별 승인 설정도 전부 데스크톱 프로세스 안에 있다. 서버에서 돌리려면 이걸 다 복제해야 한다. 기능 인덱스에도 Graph 보기는 브라우저에서 되지만 실행은 데스크톱이 필요하다고 적혀 있다.

밖에서 접속 못 하는 데스크톱에 요청이 어떻게 가나

안 간다. 데스크톱이 가지러 온다. 메인 창에 한 번 마운트되는 apps/web/src/hooks/useConnectedGraphRelay.ts가 하는 일은 이렇다.

  1. 20초마다(그리고 공유 설정이 바뀐 직후) 깬다. 이전 회차가 아직 바쁘면 건너뛴다
  2. 지난번에 서버에 못 보낸 완료 보고를 로컬 outbox에서 다시 보낸다
  3. 오너가 켠 프로젝트마다 POST /external-assistants/graph-relays/sync를 친다. 그 프로젝트에 체크된 세션 메타데이터(이름, 설명, slug, 버전, 상태)를 최대 100개 보내고, 응답으로 그 프로젝트에 쌓인 커맨드를 받는다
  4. 커맨드마다 프로젝트가 아직 켜져 있는지, 세션이 아직 체크돼 있는지, slug가 그대로인지 다시 본다. 아니면 "Graph session is no longer shared with this project"로 cancelled 처리한다
  5. 괜찮으면 채널 graph:external:<command id>로 run_graph_now를 부르고, 3초마다 취소 여부를 확인하다가, 끝나면 크기 제한 걸린 결과를 보낸다

결국 list_graph_sessions가 보여 주는 건 마지막 sync 때 올라온 내용이고, run_graph_session은 데스크톱이 다음에 왔을 때 집어 갈 행을 하나 써 두는 것뿐이다. 커맨드 ID는 UUID고, ConnectedGraphProjectBinding이 그 외 형식은 튕겨 낸 다음 실행을 연결된 프로젝트에 묶는 채널→프로젝트 행을 쓴다.

다시 짠 것

이 Mac엔 Claude 바이너리가 없어서 sim.py가 세 역할을 전부 가상 시계 위에서 연기한다. 데스크톱 쪽은 위 릴레이를 그대로 옮겼다. 20초 폴링, 바쁘면 건너뛰기, 커맨드 직렬 처리, 실행 전 재확인, 3초 취소 확인, outbox, 결과 10만 자 제한. 서버 쪽은 대역이다. 코드가 공개돼 있지 않으니까 툴 설명이 약속하는 것만 구현했다. 큐, 시작한 에이전트만 읽을 수 있는 결과, queued냐 실행 중이냐에 따라 갈리는 취소. 멱등 키 동작은 osFoundry CLI 가이드의 "응답을 잃어버리면 멱등 키를 재사용하라"에 맞췄다. 클라이언트는 각본대로 움직이는 Claude다.

Graph 실행 시간은 합성값이다. 로그정규분포, 중앙값 60초, 최소 5초. 실험마다 시드 고정으로 500~2,000번 돌렸고, 요청 시점과 릴레이 타이머의 위상은 랜덤이다. Python 3.9.6, Apple M1, 전체 실행 2.8초. 출력은 code/run.out에 있다.

큐에서 얼마나 기다리나

pickup wait (queued->started), n=2000: mean 9.8 s, median 9.8 s, p95 18.9 s, max 20.0 s 

최대 폴링 간격 한 번, 평균은 그 절반. 밖으로만 통신하는 데스크톱의 값이다. 포트도 안 열고 오래 붙어 있는 소켓도 없으니, 노트북 덮개를 닫아도 그냥 "아직 안 가지러 온 릴레이"가 될 뿐이다.

놓치기 쉬운 비용이 하나 더 있다. 릴레이는 커맨드를 하나씩 돌리고, 바쁜 동안은 타이머를 건너뛴다. 다섯 개를 한꺼번에 쌓으면 차례로 끝난다.

한 번에 쌓은 수 첫 결과(중앙값) 마지막 결과(중앙값) 마지막 결과(p95)
1 74초 74초 254초
3 73초 233초 483초
5 75초 399초 776초

시간 아끼려고 Graph를 다섯 개 던지는 에이전트가 제일 느린 스케줄을 받는다. 데스크톱 한 대는 워커 하나다. 병렬이 필요하면 엔진이 max_concurrency랑 토큰 상한으로 관리해 주는 Graph 안에서 하는 게 맞다.

get_graph_run은 몇 초마다 쳐야 하나

Claude 쪽으로 밀어 주는 경로도 없다. 어떤 전략이든 호출 수와 결과를 늦게 알아채는 시간의 트레이드오프다. 행마다 2,000번.

폴링 방식 실행당 호출(평균) p95 결과 늦게 알아챔(평균) p95
2초마다 47.1 118 1.0초 1.9초
5초마다 19.1 48 2.5초 4.8초
15초마다 6.7 16 7.5초 14.2초
30초마다 3.6 8 14.9초 28.4초
지수 백오프 2, 4, 8 … 30초 6.6 11 14.8초 28.4초
20초 기다렸다 10초마다 8.8 23 5.0초 9.6초

2초마다 치면 툴 호출 47번, 즉 모델 컨텍스트를 47번 왕복시켜서 1분짜리 작업의 몇 초를 줄이는 셈이다. 지수 백오프는 똑똑해 보이는데 여기선 진다. 호출 수는 15초 고정이랑 같은데 알아채는 건 두 배 늦다. 보통 Graph가 끝날 즈음엔 이미 30초 상한에 붙어 있어서다. 마지막 행은 릴레이 성질을 그대로 이용한다. 첫 폴링 간격 동안은 어차피 아무것도 시작 못 하니까, 한 간격쯤 기다렸다가 보러 가도 손해가 거의 없다.

취소는 뭘 멈추나

cancel at a random 0-40 s after queueing, n=2000: still queued 523, already running 1370 queued : terminal after 0.0 s (graph never started) running: terminal after mean 2.0 s, p95 3.3 s, max 3.5 s 

queued 취소는 서버 쪽 상태 변경으로 끝난다. 데스크톱은 그 커맨드를 보지도 않고 토큰도 안 쓴다. 실행 중 취소는 플래그다. 데스크톱이 3초 확인에서 읽고, fleet_started 이벤트로 받아 둔 run ID에 fleet_cancel_run을 부른다. 릴레이는 이 취소를 한 번만 보내고, 플래그와 정지 사이에 Graph가 끝나 버려도 결과는 cancelled로 보고한다.

멱등 키가 지켜 주는 것

idempotency_key는 필수고 8~128자다. 회차마다 200건, 응답의 30%를 버려서 클라이언트가 재시도하게 했다.

재시도할 때 키 보낸 호출 쌓인 Graph 실행
매번 새 키 266 / 302 / 286 266 / 302 / 286
같은 키 270 / 291 / 275 200 / 200 / 200

메일 초안을 만들거나 파일을 쓰는 Graph라면 중복은 1분 낭비가 아니라 부작용 두 번이다. 스키마에서 키가 필수고 툴에 destructiveHint: true가 붙은 이유다.

데스크톱이 꺼지면

desktop offline 600 s: statuses ['queued'], executed 0 back online: first start +3 s, all 5 ['completed'] by +131 s, executed 5 completion POST lost once, outbox=on : status completed, delivered 40 s after start completion POST lost once, outbox=off: status running, never delivered 

큐는 서버에 있으니 데스크톱이 꺼져 있어도 쌓인 일은 안 사라진다. 약한 건 돌아오는 길이다. Graph는 끝났는데 완료 보고 POST가 날아가면, 결과를 메모리에만 들고 있는 릴레이에선 실행이 영원히 running으로 남는다. 실제 릴레이는 보내기 전에 완료 보고를 localStorage에 쓰고 2xx를 받은 뒤에 지운다. 그래서 20초 뒤 다음 회차에 도착한다.

결과는 얼마나 클 수 있나

릴레이는 {execution_id, status, result, total_tokens, reported_cost}를 JSON.stringify한 길이가 10만 자 이하일 때만 보낸다. 넘으면 {execution_id, status, result_truncated: true}가 된다. 에러는 2,000자에서 자른다. 제한은 바이트가 아니라 글자 수다.

result text that still fits (ascii): 99,899 chars = 99,899 UTF-8 bytes result text that still fits (japanese): 99,899 chars = 299,697 UTF-8 bytes 

한글도 한 글자 3바이트라 비슷하게 300KB 가까이 실린다. 자르는 방식은 전부 아니면 없음이라 반쯤 잘린 결과를 잘못 읽을 일은 없고, 전체 기록은 그 execution_id로 데스크톱 이력에 남는다. 어느 쪽이든 모델 앞에 놓기엔 너무 많은 양이라, 에이전트가 부를 Graph는 마지막에 요약을 돌려주게 짜 두는 게 낫다.

실행은 누가 볼 수 있나

another agent reads the run: {'error': 'Graph run not found or access removed'} 

대역 서버에서 다른 연결이 Claude의 command_id를 물으면, 실제 서버가 지어낸 ID에 돌려준 것과 같은 에러가 난다. get_graph_run도 cancel_graph_run도 설명에서 대상을 "started by this Connected agent"로 한정한다. "네 거 아님"과 "없음"을 구분하지 않으니까 ID가 새도 아무것도 확인해 주지 않는다.

직접 붙인다면

  • 툴을 가진 머신이 일을 가지러 오게 한다. 밖으로 나가는 폴링이면 NAT도 슬립도 노트북 덮개도 상관없다. 대신 호출하는 쪽은 평균 간격의 절반을 기다리니까 그걸 감안해서 간격을 정한다
  • 시작 툴은 접수 번호를 돌려주고, 결과 읽는 툴 이름을 설명에 적는다. 이름만 보고 모델이 큐를 눈치채길 바라면 안 된다
  • 첫 상태 확인은 픽업 간격 한 번만큼 뒤에 둔다. 작업 길이가 1분 근처에 몰려 있으면 지수 백오프보다 고정 간격이 낫다
  • 시작 호출엔 스키마에서 멱등 키를 필수로 둔다. 부작용 있는 작업에서 키 없는 재시도는 두 번째 실행이다
  • 워커는 완료 보고를 보내기 전에 저장한다. 왕복에서 다시 만들기 제일 비싼 게 결과다
  • 공유 동의는 쌓을 때만이 아니라 실행 직전에 워커에서 한 번 더 확인한다. 그 사이에 설정이 바뀔 수 있다

밑에 깔린 프로토콜의 짧은 정의는 osFoundry MCP 용어 설명에, Graph가 속한 큰 패턴은 에이전트 오케스트레이션 용어 설명에 있다.