ouroboros · MCP 서버: 도구 정의·노출과 Subagent 디스패치
ouroboros · MCP 서버: 도구 정의·노출과 Subagent 디스패치
한 줄 요약
ouroboros는 자기 기능(인터뷰·실행·평가·진화)을 AI 모델이 버튼처럼 눌러 쓰는 “도구(tool)“로, MCP 서버를 통해 바깥에 내놓는다. 왜 배우나: 이 층을 알면 “모델이 어떻게 ouroboros를 직접 운전하는가”와 “무거운 작업을 누가 대신 처리하는가”가 보인다.
그림
flowchart TD
A[".mcp.json: uvx ouroboros mcp serve 등록"] --> B["서버 기동: get_ouroboros_tools 가 핸들러를 ToolRegistry에 일괄 등록"]
B --> C["tools/list: 도구 명세를 모델에게 광고"]
C --> D["모델이 tools/call(도구이름, 인자) 호출"]
D --> E["보안 검사: 인증→레이트리밋→인가→입력검증"]
E --> F["registry.call 이 이름으로 핸들러를 찾아 handle 실행"]
F --> G{"플러그인으로 위임할까?"}
G -->|아니오 in-process| H["서버가 직접 실행"]
H --> H2{"오래 걸리는 작업인가?"}
H2 -->|예| I["JobManager가 백그라운드 잡 생성 → job_id만 즉시 반환"]
H2 -->|아니오| J["바로 결과 반환"]
I --> K["job_status / job_wait / job_result 로 진행 폴링"]
G -->|예 OpenCode plugin| L["역할 프롬프트(agents/*.md) 조립"]
L --> M["_subagent 봉투를 결과 meta에 실어 반환"]
M --> N["OpenCode 브리지가 자식 세션을 띄워 LLM 작업 위임 → 결과 회수"]
쉽게 풀기
1) MCP 서버 = ouroboros의 리모컨
ouroboros 안에는 “인터뷰·실행·평가·Ralph 루프” 같은 기능이 많다. 이를 AI 모델(클로드 코드 등)이 직접 쓰게 리모컨 버튼처럼 내놓는 창구가 MCP 서버다. .mcp.json에 uvx ... ouroboros mcp serve 한 줄만 넣으면, 모델 화면에 ouroboros_execute_seed, ouroboros_interview, ouroboros_evaluate, ouroboros_ralph 같은 버튼 수십 개가 한꺼번에 뜬다.
2) 버튼 하나 = 핸들러 클래스 하나
각 버튼 뒤에는 파이썬 핸들러 클래스가 한 개씩 붙는다(예: ExecuteSeedHandler). 핸들러는 두 가지만 갖추면 된다 — (a) definition(이름·설명·입력칸을 적은 이름표), (b) handle()(눌렀을 때 도는 함수). 모델은 이름표만 보고 “언제 누를지”를 판단한다.
3) 안내데스크 — ToolRegistry
모델이 “이 이름의 도구를 실행해줘” 하면 ToolRegistry가 이름으로 핸들러를 찾아 handle()을 호출한다(호텔 프런트가 객실 번호로 방을 찾듯). 같은 이름이 두 번 등록되면 헷갈리므로 등록 단계에서 오류로 막는다.
4) 오래 걸리는 일은 번호표부터 — JobManager
인터뷰·실행처럼 몇 분 걸리는 작업을 끝까지 기다리면 모델이 멈춘다. 그래서 진동벨처럼 번호표(job_id)를 즉시 돌려주고 뒤에서 작업을 돌린다. 모델은 ouroboros_job_status / wait / result로 폴링해 결과를 회수한다. 진행 상태는 이벤트로 기록돼 중간에 끊겨도 복원된다.
5) 무거운 일은 자식에게 — _subagent 봉투
OpenCode 환경에서 돌 때는 LLM을 직접 부르지 않는다. 대신 “너는 소크라테스식 면접관이야” 같은 역할 설명서(agents/socratic-interviewer.md)를 봉투에 담아 돌려준다. OpenCode 브리지가 봉투를 받아 자식 세션(서브에이전트)을 띄우고 대화를 맡긴다. 부모는 짐을 내려놓고, 작업은 보이는 자식 창에서 진행돼 관측·병렬이 쉽다.
flowchart LR
M["모델 tools/call"] --> R["ToolRegistry: 이름→핸들러"]
R --> H["handle()"]
H --> Q{"should_dispatch_via_plugin?"}
Q -->|in-process| J["JobManager 번호표 or 즉시 결과"]
Q -->|plugin| S["_subagent 봉투 → 자식 세션"]
핵심 정리
| 구성요소 | 한 줄 역할 | 비유 |
|---|---|---|
| MCP 서버 | 기능을 도구로 노출 | 리모컨 |
핸들러(definition+handle) | 도구 1개 이름표 + 실행 | 버튼 |
ToolRegistry | 이름으로 핸들러 찾아 실행 | 호텔 프런트 |
JobManager | 오래 걸리는 작업을 백그라운드로 | 진동벨/번호표 |
_subagent 봉투 | 역할 프롬프트를 자식에게 위임 | 외주 의뢰서 |
도구 호출은 두 길로 갈린다. 분기 판단은 should_dispatch_via_plugin()이 전담하며, 봉투를 잘못 내보내면 받을 플러그인이 없어 흐름이 깨진다.
flowchart TD
C["tools/call"] --> G{"runtime + opencode_mode"}
G -->|"claude / codex / None"| P1["in-process: 직접 실행, 길면 JobManager"]
G -->|"OpenCode + plugin"| P2["plugin: _subagent 봉투만 반환, LLM은 자식이 호출"]
[!note]- 펼쳐보기: 핵심 데이터 형식 4종 (필드 상세) 필드 표는 실제 예시와 함께 본다.
MCPToolDefinition— 도구 이름표(name/description/parameters).name이 레지스트리 키.MCPToolParameter— 입력칸 하나의 정의.to_input_schema()로 JSON Schema화.SubagentPayload— 자식에게 보낼 봉투(tool_name/title/prompt/agent/timeout…).JobSnapshot— 백그라운드 잡의 현재 상태(job_id/status/links/결과).
MCPToolDefinition(src/ouroboros/mcp/types.py:284)
필드 타입 설명 namestr전역 유일 도구명 (예: ouroboros_ralph)descriptionstr모델이 “언제 쓸지” 판단하는 자연어 설명 parameterstuple[MCPToolParameter, ...]입력 파라미터(기본 빈 튜플) server_namestr | None제공 서버명
MCPToolParameter→ JSON Schema 프로퍼티
필드 타입 설명 namestr파라미터명 typeToolInputType(STRING/BOOLEAN/INTEGER/NUMBER/ARRAY…)JSON Schema type으로 매핑requiredboolTrue면 schema required[]에 추가default/enum/items각각 None이 아니면 schema에 병합
SubagentPayload(subagent.py)
필드 타입 설명 tool_namestr디스패치를 일으킨 MCP 도구명 titlestrTUI 서브에이전트 패널 제목 promptstr자식 LLM에게 줄 전체 프롬프트(역할 프롬프트 포함) agentstrOpenCode 서브에이전트 타입(기본 "general")model/context/timeout각각 모델 오버라이드 / 원본 인자 / 자식 타임아웃 천장
JobSnapshot
필드 타입 설명 job_idstrjob_<uuid12>statusJobStatusQUEUED/RUNNING/CANCEL_REQUESTED/COMPLETED/FAILED/CANCELLED/INTERRUPTED linksJobLinkssession_id / execution_id / lineage_id 교차참조 result_*/error/is_terminal터미널 결과 및 종료 여부 판정
실제 예시
핸들러가 도구 이름표를 만드는 방식, 레지스트리가 이름으로 찾아 실행하는 방식, 봉투 생성과 게이트 판정까지 — 전체 코드는 아래에 접어둔다.
[!note]- 펼쳐보기: 전체 코드 예시 (핸들러·레지스트리·봉투·게이트) 핸들러가 도구 이름표를 만든다 — 모델에게 “이 버튼은 이렇게 생겼다”를 알린다.
# src/ouroboros/mcp/tools/ralph_handlers.py class RalphHandler: """Start a runtime-owned Ralph loop as a background job.""" @property def definition(self) -> MCPToolDefinition: return MCPToolDefinition( name="ouroboros_ralph", description=( "Start a first-class Ralph loop in the background. ... In non-plugin " "runtimes, returns a job_id immediately for ouroboros_job_status, " "ouroboros_job_wait, ouroboros_job_result, and ouroboros_cancel_job. " "In OpenCode plugin mode, returns job_id=None and delegates the loop " "to the plugin child session." ), parameters=( MCPToolParameter( name="lineage_id", type=ToolInputType.STRING, description="Lineage ID to start or continue.", required=True, ), MCPToolParameter( name="max_generations", type=ToolInputType.INTEGER, description="Maximum generations ... Default: 10. Range: 1-10.", required=False, default=MAX_RALPH_GENERATIONS, ), # ... per_iteration_timeout_seconds, skip_qa, parallel, ... ), )안내데스크(레지스트리)가 이름으로 핸들러를 찾아 실행한다.
# src/ouroboros/mcp/tools/registry.py def register(self, handler: ToolHandler, *, category: str = "default") -> None: name = handler.definition.name if name in self._handlers: raise ValueError(f"Tool already registered: {name}") self._handlers[name] = handler # 이름 → 핸들러 async def call(self, name: str, arguments: dict[str, Any]) -> Result[MCPToolResult, MCPServerError]: handler = self._handlers.get(name) if not handler: return Result.err(MCPResourceNotFoundError(f"Tool not found: {name}", ...)) result = await handler.handle(arguments) # 실제 실행 return result
_subagent봉투 생성과, “지금 봉투를 내보낼 환경인가”를 판정하는 게이트.# src/ouroboros/mcp/tools/subagent.py def build_subagent_result(payload: SubagentPayload, *, response_shape=None) -> Result: body: dict[str, Any] = {} if response_shape: body.update(response_shape) # #442: status/job_id 등 공개 계약 필드 병존 body["_subagent"] = payload.to_dict() return Result.ok(MCPToolResult( content=(MCPContentItem(type=ContentType.TEXT, text=_canonical_response_json(body)),), is_error=False, meta=dict(body), # 플러그인은 meta._subagent 를 읽음 )) _OPENCODE_RUNTIMES = frozenset({"opencode", "opencode_cli"}) def should_dispatch_via_plugin(runtime_backend, opencode_mode) -> bool: # OpenCode 런타임 + opencode_mode=="plugin" 일 때만 봉투를 내보낸다. # 그 외(claude/codex/None)는 봉투 받을 데가 없으므로 in-process 실제 경로 실행. if (runtime_backend or "").strip().lower() not in _OPENCODE_RUNTIMES: return False return (opencode_mode or "").strip().lower() == "plugin"
새 도구 1개를 추가하려면 핸들러 한 개를 만들어 두 경로(in-process / plugin)를 분기하고, get_ouroboros_tools() 반환 튜플에 MyHandler(...)를 추가하면 서버 기동 시 자동 등록·노출된다.
[!note]- 펼쳐보기: 새 도구 최소 핸들러 템플릿
# src/ouroboros/mcp/tools/my_handler.py from dataclasses import dataclass from ouroboros.core.types import Result from ouroboros.mcp.types import ( MCPToolDefinition, MCPToolParameter, ToolInputType, MCPToolResult, MCPContentItem, ContentType, ) from ouroboros.mcp.tools.subagent import ( build_subagent_payload, dispatch_plugin_terminal, should_dispatch_via_plugin, DELEGATED_TO_SUBAGENT, ) from ouroboros.agents.loader import load_agent_prompt @dataclass class MyHandler: agent_runtime_backend: str | None = None opencode_mode: str | None = None @property def definition(self) -> MCPToolDefinition: return MCPToolDefinition( name="ouroboros_my_tool", description="What it does and when the model should call it.", parameters=( MCPToolParameter(name="target", type=ToolInputType.STRING, description="...", required=True), ), ) async def handle(self, arguments: dict) -> Result: target = arguments["target"] if should_dispatch_via_plugin(self.agent_runtime_backend, self.opencode_mode): system_prompt = load_agent_prompt("my-role") # agents/my-role.md payload = build_subagent_payload( tool_name="ouroboros_my_tool", title=f"My tool: {target}", prompt=f"{system_prompt}\n\n## Your Task\n{target}", context={"target": target}, ) return await dispatch_plugin_terminal( None, session_id=None, payload=payload, response_shape={"status": DELEGATED_TO_SUBAGENT, "dispatch_mode": "plugin"}, ) # in-process 경로 return Result.ok(MCPToolResult( content=(MCPContentItem(type=ContentType.TEXT, text=f"done: {target}"),), is_error=False, ))
[!note]- 펼쳐보기: 새 도구 만들 때 체크리스트
definition.name이 전역 유일한가(ouroboros_접두) — 중복이면register가ValueError.handle()이async이고Result[MCPToolResult, ...]를 반환하는가.- long-run이면 직접 await하지 말고
JobManager.start_job으로job_id즉시 반환했는가(+JobLinks로 session/execution/lineage 연결).- plugin 분기를
should_dispatch_via_plugin로 게이트했는가.- subagent 프롬프트를 인라인 문자열이 아니라
load_agent_prompt(<role>)(agents/*.md)에서 로드했는가.- 프롬프트의 트랜스크립트/컨텍스트를
_truncate_*/_compact_interview_transcript로 바운딩했는가.- freetext가 아닌 인자에
;,|,../등을 넣지 않는가(InputValidator가 차단; 코드/프롬프트 필드는FREETEXT_FIELDS예외).get_ouroboros_tools()튜플(또는OUROBOROS_TOOLS정적 목록)에 등록했는가.
요약 & 셀프체크
3줄 요약:
- MCP 서버는 기능을 도구(버튼)로 노출하고, 핸들러 1개 = 도구 1개이며
ToolRegistry가 이름으로 찾아 실행한다. - 오래 걸리는 작업은
JobManager가job_id(번호표)를 즉시 돌려주고 백그라운드로 처리하며, 상태는 이벤트로 영속된다. - OpenCode plugin 환경에서는 LLM을 직접 부르지 않고
_subagent봉투(역할 프롬프트 =agents/*.md)를 자식 세션에 위임한다.
스스로 답해보기:
- 모델이 도구를 부르면 어떤 검사를 거쳐 어느 함수가 실제로 실행되는가?
- 같은 도구라도 in-process 경로와 plugin 경로로 갈리는 기준은 무엇인가?
- 인터뷰처럼 몇 분 걸리는 작업에서 모델이 멈추지 않게 하는 장치는 무엇이고, 결과는 어떻게 회수하는가?
연결
OB_개요 · _분석축_루브릭 · OB_40_orchestrator-execution-loop · OB_50_provider-adapters-and-backend-neutral-runtime · OB_70_extension-points-hooks-skills-commands
[!tip]- Codex 교차검증 (원문 분석 보존) 원 노트는 소스 근거에 기반해 다음을 확인했다. (1) 도구 노출 경로:
.mcp.json→uvx ... ouroboros mcp serve→get_ouroboros_tools(...)→ToolRegistry일괄 등록 →definition.to_input_schema()를tools/list로 광고. (2) 호출 시SecurityLayer.check_request(인증→레이트리밋→인가→입력검증) 후registry.call(name, args)실행. (3) 분기는should_dispatch_via_plugin()이 OpenCode 런타임 +opencode_mode=="plugin"일 때만 봉투 경로로 보내고, 그 외(claude/codex/None)는 in-process 실제 경로 실행. (4) 프롬프트는 2-tier 로딩(OUROBOROS_AGENTS_DIR오버라이드 → 패키지 번들)으로agents/*.md를 시스템 프롬프트로 깔고## Your Task·세션ID·트랜스크립트(길이 바운딩)·Seed-ready Guard를 덧붙인다 = “agents/*.md가 subagent의 정체성”.근거 파일:
- /home/seunghyeong/harness-work/ouroboros/.mcp.json
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/tools/definitions.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/tools/registry.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/tools/subagent.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/tools/ralph_handlers.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/types.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/job_manager.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/agents/socratic-interviewer.md
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/agents/loader.py
- /home/seunghyeong/harness-work/ouroboros/src/ouroboros/mcp/server/security.py
- /home/seunghyeong/harness-work/ouroboros/CLAUDE.md