지식위키

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.jsonuvx ... 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_ 접두) — 중복이면 registerValueError.
  • 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줄 요약:

  1. MCP 서버는 기능을 도구(버튼)로 노출하고, 핸들러 1개 = 도구 1개이며 ToolRegistry가 이름으로 찾아 실행한다.
  2. 오래 걸리는 작업은 JobManagerjob_id(번호표)를 즉시 돌려주고 백그라운드로 처리하며, 상태는 이벤트로 영속된다.
  3. 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.jsonuvx ... ouroboros mcp serveget_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