Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/ouroboros/mcp/tools/execution_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,19 @@ def definition(self) -> MCPToolDefinition:
required=False,
default=False,
),
MCPToolParameter(
name="additional_tools",
type=ToolInputType.ARRAY,
description=(
"Optional list of additional tool names to make available during "
"execution. These are merged with the default tool set (Read, Write, "
"Edit, Bash, Glob, Grep). Use this to pass MCP tools from the parent "
"Claude session (e.g. ['mcp__github__issue_read', "
"'mcp__chrome-devtools__click'])."
),
required=False,
items={"type": "string"},
),
),
)

Expand Down Expand Up @@ -258,6 +271,21 @@ async def handle(
None if is_resume else _extract_inherited_effective_tools(arguments)
)

# Merge caller-supplied additional_tools (only for new executions)
additional_tools = arguments.get("additional_tools") if not is_resume else None
if isinstance(additional_tools, list) and additional_tools:
extra = list(dict.fromkeys(t for t in additional_tools if isinstance(t, str) and t))
if extra:
if inherited_effective_tools:
seen = set(inherited_effective_tools)
inherited_effective_tools = list(inherited_effective_tools)
for tool in extra:
if tool not in seen:
inherited_effective_tools.append(tool)
seen.add(tool)
else:
inherited_effective_tools = extra

log.info(
"mcp.tool.execute_seed",
session_id=session_id,
Expand Down
167 changes: 167 additions & 0 deletions tests/unit/mcp/tools/test_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,173 @@ def test_start_evolve_step_definition_name(self) -> None:
"""


class TestAdditionalToolsParameter:
"""Tests for the additional_tools parameter on ExecuteSeedHandler."""

def test_definition_includes_additional_tools_parameter(self) -> None:
"""ExecuteSeedHandler exposes additional_tools in its tool schema."""
handler = ExecuteSeedHandler()
param_names = {p.name for p in handler.definition.parameters}
assert "additional_tools" in param_names

at_param = next(p for p in handler.definition.parameters if p.name == "additional_tools")
assert at_param.required is False
assert at_param.type == ToolInputType.ARRAY
assert at_param.items == {"type": "string"}

async def test_additional_tools_merged_into_inherited_tools(self) -> None:
"""additional_tools from arguments are passed as inherited_tools to OrchestratorRunner."""
handler = ExecuteSeedHandler()
mock_runtime = MagicMock()
mock_event_store = AsyncMock()
mock_event_store.initialize = AsyncMock()
mock_exec_result = MagicMock(
success=True,
session_id="s",
execution_id="e",
messages_processed=1,
duration_seconds=0.1,
final_message="done",
summary={},
)
mock_runner = MagicMock()
prepared_tracker = SessionTracker.create("exec-at", "seed-at", session_id="sess-at")
mock_runner.prepare_session = AsyncMock(return_value=Result.ok(prepared_tracker))
mock_runner.execute_precreated_session = AsyncMock(return_value=Result.ok(mock_exec_result))
mock_runner.resume_session = AsyncMock()

with (
patch(
"ouroboros.mcp.tools.execution_handlers.create_agent_runtime",
return_value=mock_runtime,
),
patch(
"ouroboros.mcp.tools.execution_handlers.EventStore",
return_value=mock_event_store,
),
patch(
"ouroboros.mcp.tools.execution_handlers.OrchestratorRunner",
return_value=mock_runner,
) as runner_cls,
):
result = await handler.handle(
{
"seed_content": VALID_SEED_YAML,
"skip_qa": True,
"additional_tools": [
"mcp__github__issue_read",
"mcp__chrome-devtools__click",
],
}
)
background_tasks = tuple(handler._background_tasks)
await asyncio.gather(*background_tasks)

assert result.is_ok
runner_kwargs = runner_cls.call_args.kwargs
assert runner_kwargs["inherited_tools"] == [
"mcp__github__issue_read",
"mcp__chrome-devtools__click",
]

async def test_no_additional_tools_preserves_default_behavior(self) -> None:
"""When additional_tools is absent, inherited_tools is None (default behavior)."""
handler = ExecuteSeedHandler()
mock_runtime = MagicMock()
mock_event_store = AsyncMock()
mock_event_store.initialize = AsyncMock()
mock_exec_result = MagicMock(
success=True,
session_id="s",
execution_id="e",
messages_processed=1,
duration_seconds=0.1,
final_message="done",
summary={},
)
mock_runner = MagicMock()
prepared_tracker = SessionTracker.create("exec-def", "seed-def", session_id="sess-def")
mock_runner.prepare_session = AsyncMock(return_value=Result.ok(prepared_tracker))
mock_runner.execute_precreated_session = AsyncMock(return_value=Result.ok(mock_exec_result))
mock_runner.resume_session = AsyncMock()

with (
patch(
"ouroboros.mcp.tools.execution_handlers.create_agent_runtime",
return_value=mock_runtime,
),
patch(
"ouroboros.mcp.tools.execution_handlers.EventStore",
return_value=mock_event_store,
),
patch(
"ouroboros.mcp.tools.execution_handlers.OrchestratorRunner",
return_value=mock_runner,
) as runner_cls,
):
result = await handler.handle({"seed_content": VALID_SEED_YAML, "skip_qa": True})
background_tasks = tuple(handler._background_tasks)
await asyncio.gather(*background_tasks)

assert result.is_ok
runner_kwargs = runner_cls.call_args.kwargs
assert runner_kwargs["inherited_tools"] is None

async def test_additional_tools_ignored_on_resume(self) -> None:
"""Resumed sessions must not pick up additional_tools — preserves original tool set."""
handler = ExecuteSeedHandler()
mock_runtime = MagicMock()
mock_event_store = AsyncMock()
mock_event_store.initialize = AsyncMock()
mock_exec_result = MagicMock(
success=True,
session_id="s",
execution_id="e",
messages_processed=1,
duration_seconds=0.1,
final_message="done",
summary={},
)
resumed_tracker = SessionTracker.create("exec-res", "seed-res", session_id="sess-res")
mock_session_repo = MagicMock()
mock_session_repo.reconstruct_session = AsyncMock(return_value=Result.ok(resumed_tracker))
mock_runner = MagicMock()
mock_runner.resume_session = AsyncMock(return_value=Result.ok(mock_exec_result))

with (
patch(
"ouroboros.mcp.tools.execution_handlers.create_agent_runtime",
return_value=mock_runtime,
),
patch(
"ouroboros.mcp.tools.execution_handlers.EventStore",
return_value=mock_event_store,
),
patch(
"ouroboros.mcp.tools.execution_handlers.OrchestratorRunner",
return_value=mock_runner,
) as runner_cls,
patch(
"ouroboros.mcp.tools.execution_handlers.SessionRepository",
return_value=mock_session_repo,
),
):
result = await handler.handle(
{
"seed_content": VALID_SEED_YAML,
"session_id": "sess-res",
"additional_tools": ["mcp__github__issue_read"],
}
)
background_tasks = tuple(handler._background_tasks)
await asyncio.gather(*background_tasks)

assert result.is_ok
runner_kwargs = runner_cls.call_args.kwargs
assert runner_kwargs["inherited_runtime_handle"] is None
assert runner_kwargs["inherited_tools"] is None


class TestMeasureDriftHandler:
"""Test MeasureDriftHandler class."""

Expand Down
Loading