osFoundry MCP 서버에 로그인 전 Codex CLI가 세션 툴을 처음 호출하면 이렇게 돌아온다.
POST /api/v1/external-mcp {"method":"tools/call","params":{"name":"propose_session_handoff","arguments":{}}} HTTP/2 401 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" {"jsonrpc":"2.0","id":3,"result":{"isError":true, "content":[{"type":"text","text":"Authentication required"}], …}} initialize랑 tools/list는 토큰 없이도 답한다. 그래서 클라이언트는 아무도 로그인하지 않은 상태에서 툴 59개를 다 볼 수 있다. 그중에 같은 일을 하는 것처럼 보이는 두 개가 있다.
{"name": "propose_session_handoff", "description": "Send a scoped task and context summary to the owner of an invited session. The owner must accept before a run is queued.", "required": ["session_type","session_id","goal","context_summary","requested_output","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"name": "request_session_run", "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.", "required": ["session_type","session_id","goal","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} 둘 다 결국 세션이 일을 하게 만든다. 이 글에서는 왜 하나로 안 합쳤는지, 각각 어떤 제한이 걸려 있는지 보고, 클라이언트 쪽을 Python으로 다시 만들어서 그 제한을 라이브 스키마에 직접 대 봤다.
두 호출을 가르는 건 뭘까
누가, 언제 OK를 하느냐다.
핸드오프는 그 자리에서 사람한테 묻는다. 오너한테 "Codex CLI requests a handoff" 알림이 가고, 세션의 Handoffs 탭 "Outside assistant requests"에 올라온다. 보이는 건 goal, 접혀 있는 "Context and expected output", 그리고 "Up to N rounds on your agent runtime" 한 줄이다. 누가 Accept and run을 누르기 전까지 큐에는 아무것도 안 들어간다. 웹 클라이언트는 /agent-sessions/{id}/external-handoffs/{handoff_id}/decision에 {decision: "accept"}를 POST하고, 토스트로 "Handoff accepted and run queued"가 뜬다. 핸드오프 행은 pending에서 accepted, declined, cancelled 중 하나로 넘어간다.
run은 승인이 이미 끝나 있다. 오너가 Connected 탭에서 연결에 Runner 자리를 준 순간이 OK다. 거기 설명문은 이렇다. "Invite it to a session as Contributor to post notes and propose handoffs, or Runner to also request runs. A handoff still needs the session owner's approval." 로그인 화면에서도 run 스코프는 "Run sessions where you grant a runner role"로 나온다.
needs_approval 플래그 하나 달린 툴로 합쳤다면, 사람한테 물을지 말지를 호출하는 쪽이 정하게 된다. 툴을 둘로 쪼개면 그 결정이 오너 쪽, 그것도 호출자가 손댈 수 없는 두 군데로 간다. OAuth 스코프(osfoundry:write냐 osfoundry:run이냐)와 자리(Contributor냐 Runner냐)다. Codex 설정의 scopes에서 osfoundry:run을 빼 두면 Runner 자리가 있어도 run을 못 띄운다.
어노테이션도 같은 선으로 갈린다. propose_session_handoff는 결과가 호출 밖의 사람한테 닿으니까 openWorldHint: true. request_session_run은 미리 동의한 세션의 큐만 건드리니까 openWorldHint: false. 둘 다 destructiveHint: true라서 Codex를 default_tools_approval_mode = "writes"로 돌리면 둘 다 실행 전에 확인을 받는다.
핸드오프만 컨텍스트 요약이 필수인 이유
뭔가 돌기 전에 사람이 읽기 때문이다. context_summary와 requested_output은 핸드오프에선 필수고 run에는 아예 없다. 오너는 자기 세션 런타임을 여기에 쓸지 말지 정해야 하는데, "export 고쳐줘"만으로는 못 정한다. Runner는 그 신뢰를 이미 받았으니 run은 goal만 받는다.
길이 제한도 다르다. 핸드오프의 goal은 5,000자, 컨텍스트 5,000자, 원하는 결과물 1,000자까지. run의 goal은 10,000자까지 된다. run엔 다른 칸이 없으니 실제로는 goal에 컨텍스트까지 같이 쓰게 된다.
Codex는 뭘 다시 읽을 수 있나
세션이 가진 것보다 적다.
-
read_session_task_result: "the status and attributed runtime result for one task started by this connection". 다른 연결의 task id를 넣어도 소용없다. -
read_session_mail: "notes addressed to this connection and notes it posted". 페이지당 1~20개, 오래된 건before_id로 넘긴다. "Internal agent analysis is excluded." -
read_session_state: 참여한 세션의 현재 상태와 최근 태스크. -
post_session_note: 이름이 붙은 노트를 남긴다. 에이전트는 "the returned external sender slug"로 답장할 수 있다. "does not trigger an automatic run"이라서 노트로 오너의 클릭을 우회할 수는 없다.
팀 세션에서는 이 노트가 Team mail에 연결의 표시 이름과 "outside assistant" 라벨을 달고 올라온다.
다시 만들어 보기
이 Mac엔 codex 바이너리가 없어서 handoff_sim.py(약 220줄)가 클라이언트 역할을 한다. 하는 일은 세 가지다.
- 토큰 없이 라이브 서버에
initialize,tools/list,tools/call을 각각 15번 보내고 툴 목록을 저장한다. - 인자 케이스 12개를 라이브
inputSchema에jsonschema(Draft 2020-12)로 검증한다. 제한값은 추측하지 않는다. - 자리, 스코프, 오너의 결정, 멱등성을 작은 인메모리 모델로 돌린다. osFoundry 서버 코드는 공개돼 있지 않아서, 이 부분은 툴 설명과 핸드오프를 보여 주는 웹 클라이언트를 보고 재구성한 거다. 응답은 모델의 것이지 서버의 것이 아니다.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py 와이어
initialize HTTP 200 median 566.3 p90 799.0 tools/list HTTP 200 median 559.7 p90 937.7 tools/call (no token) HTTP 401 median 482.6 p90 677.0 단위는 밀리초. 일본에 있는 이 Mac에서 CloudFront KIX 엣지를 거쳐 잰 거라 서버보다는 경로 숫자에 가깝다. 볼 건 모양이다. 툴 탐색은 공짜고 Codex 켤 때마다 돌려도 부담이 없다. 첫 진짜 호출은 401 왕복 한 번이 들고, 그 401이 resource_metadata를 실어 온다. Codex는 따로 알려 주지 않아도 이걸로 OAuth 서버를 찾는다.
세션 툴 7개는 전체 목록 10,197토큰 중 1,152토큰(11.3%)이었다. cl100k_base로 센 거라 Codex 토크나이저 기준으로는 대략적인 값이다. enabled_tools에 이 7개만 넣으면 프롬프트가 작아진다.
제한값
| 툴 | 스코프 | 필수 | 제한 |
|---|---|---|---|
propose_session_handoff | write | 6 | goal 5,000, context 5,000, output 1,000, max_rounds 1–10 |
cancel_session_handoff | write | 1 | handoff_id만 |
request_session_run | run | 4 | goal 10,000, max_rounds 1–10 |
read_session_state | read | 2 | 없음 |
read_session_task_result | read | 3 | 없음 |
read_session_mail | read | 2 | limit 1–20 |
post_session_note | write | 5 | subject 200, body 10,000 |
라이브 스키마에 12케이스를 대 본 결과다.
propose_session_handoff 6,000-char goal REJECT maxLength: goal request_session_run 6,000-char goal ok propose_session_handoff no context_summary REJECT required propose_session_handoff max_rounds omitted ok request_session_run max_rounds 0 REJECT minimum: max_rounds request_session_run max_rounds 11 REJECT maximum: max_rounds request_session_run no idempotency_key REJECT required request_session_run session_type 'room' REJECT enum: session_type request_session_run extra field 'model' REJECT additionalProperties read_session_mail limit 21 REJECT maximum: limit post_session_note 201-char subject REJECT maxLength: subject cancel_session_handoff only handoff_id ok 두 개는 다시 볼 만하다. 같은 6,000자 goal이 run에선 통과하고 핸드오프에선 너무 길다. 긴 프롬프트 하나 만들어서 쓸 수 있는 툴에 아무거나 던지는 클라이언트는 여기서 삽질한다. 그리고 additionalProperties: false가 model 필드를 막는다. 호출자는 세션 모델을 고를 수 없고, 세션은 자기 설정대로 돈다.
max_rounds는 둘 다 생략할 수 있다. 기본값이 스키마에 안 나와 있으니 횟수가 중요하면 명시해서 보내는 게 낫다.
자리, 취소, 승인 (모델)
tool observer contributor runner propose_session_handoff - yes yes cancel_session_handoff - yes yes request_session_run - - yes read_session_state yes yes yes read_session_task_result yes yes yes read_session_mail yes yes yes post_session_note - yes yes runner seat, token without osfoundry:run -> {'error': 'token lacks osfoundry:run'} cancel while pending -> {'status': 'cancelled'} owner clicks Accept after cancel -> cancelled owner accepts -> accepted | cancel after -> {'error': 'already accepted'} another connection reads that task -> {'error': 'not a task started by this connection'} 표는 Connected 탭 설명문을 그대로 표로 옮긴 거다. 재밌는 건 취소 창이다. cancel_session_handoff는 "before its owner accepts it"에만 먹히니까, 먼저 일어난 쪽이 이긴다. run이 큐에 들어간 뒤엔 거두는 게 더 이상 호출자 몫이 아니다. 취소가 handoff_id만 받는 것도 그래서다. 넘길 상태가 없고, 닫을 건 대기 중인 행 하나뿐이다.
응답이 사라질 때
쓰기 툴은 전부 idempotency_key가 필수다. 이게 뭘 막는지 보려고 모델에서 request_session_run을 1,000번 보내고, 응답의 20%를 버리고(seed 7), 클라이언트가 같은 키로 최대 3번 재시도하게 했다.
server honours idempotency_key=True calls sent 1276 tasks queued 1000 duplicates 0 server honours idempotency_key=False calls sent 1276 tasks queued 1276 duplicates 276 남의 런타임에서 run이 276번 더 도는 거다. 필수 키는 바로 이 경우 때문에 있다. 타임아웃 나서 재시도하는 클라이언트라면 누구나 밟을 수 있고, 이미 시작된 run은 두 번 필요 없다. 선택이 아니라 필수라서 빼먹는 클라이언트가 나올 수 없다.
스크립트 전체는 Apple M1에서 실시간 26.4초 걸렸다. 거의 다 네트워크 호출 45번이고 user CPU 시간은 0.37초였다.
직접 붙인다면
- "사람한테 묻기"와 "미리 받은 허락으로 움직이기"를 스코프가 다른 두 툴로 나눈다. 오너의 선택은 호출자가 정하는 인자가 아니라 토큰과 자리에 둔다.
- 사람이 판단할 때 쓰는 필드(
context_summary,requested_output)는 제안 쪽에만 필수로 두고, run의 goal보다 제한을 빡빡하게 잡는다. - 호출자가 취소할 수 있는 건 대기 중일 때뿐이다. 승인 후에 멈추는 건 오너 일이다.
- 다시 읽기는 그 연결이 시작한 것, 그 연결 앞으로 온 것으로 좁힌다. 세션 내부 분석은 빼 둔다.
- 쓰기는 전부
idempotency_key필수로 하고, 중복이면 첫 결과를 돌려준다. - 스키마는
additionalProperties: false로 닫아서, 세션이 고르지 않은 모델이나 툴 목록을 호출자가 끼워 넣지 못하게 한다.
osFoundry 에이전트끼리 단계 사이에 일을 넘기는 방식은 핸드오프 페이지에 있다. 밑에 깔린 프로토콜은 MCP 용어 설명을 보면 된다.