Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ Delegate work through the usual command:
landing review "Check http://127.0.0.1:8000. Reproduce navigation problems and report concise evidence."
```

CLI, server, and Action calls use the same MCP configuration. For a service or CI job, make the configuration and executables available in its execution environment.
CLI, server, and Action calls use the same MCP configuration. For a service or CI job, make the configuration and executables available in its execution environment. A configured server's tools do not appear in the model's tool list directly; the task prompt lists them and the model loads the ones it needs before calling them, so a review that depends on a browser tool spends a step on discovery.
4 changes: 2 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ Filters control method loading and model tool calls across entry points. They do
}
```

Use the `mcpServers` JSON format shown above. Native YAML `mcp.config_path` configures the final fallback and `mcp.init_timeout_seconds` controls connection startup. Server commands run in their prepared environment; use absolute command, argument, and output paths when working across repositories.
Use the `mcpServers` JSON format shown above. Native YAML `mcp.config_path` configures the final fallback, `mcp.init_timeout_seconds` controls connection startup, and `mcp.deferred_tools: false` offers server tools directly instead of loading them on demand. Server commands run in their prepared environment; use absolute command, argument, and output paths when working across repositories.

Discovered tools use names such as `mcp.playwright_browser_navigate`. Mode tool limits apply after discovery. A configured server that cannot connect fails the task before model work; connection details remain in runner logs. See [Use MCP servers](../guides/mcp.md) for setup and an example.
Discovered tools use names such as `mcp.playwright_browser_navigate` and load on demand: the task prompt lists the names its mode allows, the model calls `tool_describe` with one, and the tool stays callable by name for the rest of the session. The SDK supplies `tool_describe` alongside a deferred list, so mode tool limits apply before loading and excluded tools never reach the model. A configured server that cannot connect fails the task before model work; connection details remain in runner logs. See [Use MCP servers](../guides/mcp.md) for setup and an example.

## Execution and service

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ keywords = ['python']
requires-python = ">=3.12,<4.0"
dependencies = [
"bub[app]==0.6.1",
"bub-mcp==0.3.0",
"bub-mcp==0.4.0",
"fastapi>=0.132",
"pydantic>=2.12",
"pydantic-settings>=2.12",
Expand Down
18 changes: 9 additions & 9 deletions src/landing/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

from bub import Settings as BubSettings
from bub import config, ensure_config
from bub.core import Agent
from bub.builtin.agent import BubAgent
from bub_mcp.config import MCPSettings, read_config
from bub_mcp.plugin import MCPChannel
from bub_mcp.plugin import MCPManager
from pydantic_settings import SettingsConfigDict


Expand Down Expand Up @@ -43,15 +43,15 @@ def configuration(workspace: Path) -> Path:


@asynccontextmanager
async def connected_tools(agent: Agent, workspace: Path) -> AsyncGenerator[MCPChannel]:
channel = MCPChannel.from_server_configs(read_config(configuration(workspace)))
async def connected_tools(agent: BubAgent, workspace: Path) -> AsyncGenerator[MCPManager]:
manager = MCPManager.from_server_configs(read_config(configuration(workspace)))
try:
await channel.connect()
failed = [name for name, server in channel.list().items() if not server.connected]
await manager.connect()
failed = [name for name, server in manager.list().items() if not server.connected]
if failed:
message = "Could not connect configured MCP servers: " + ", ".join(failed)
raise RuntimeError(message)
channel.bind_agent(agent)
yield channel
manager.bind_agent(agent)
yield manager
finally:
await channel.stop()
await manager.stop()
7 changes: 7 additions & 0 deletions src/landing/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re

from bub.builtin.tools import render_deferred_tools_prompt
from bub.core import PromptContext, SystemPrompt
from bub.prompt import prompt_text
from bub.skills import render_skills_prompt
Expand Down Expand Up @@ -42,6 +43,12 @@ def repository(context: PromptContext) -> str:
def workspace(context: PromptContext) -> str:
return f"Task workspace: {workspace_from_state(context.state)}"

@prompt.add
def deferred_tools(context: PromptContext) -> str:
# Landing owns its sections, so the list of tools the model can load needs its own section.
tools = [tool for tool in context.available_tools if tool.deferred and tool.exposure in ("auto", "direct")]
return render_deferred_tools_prompt(tools)

@prompt.add
def skills(context: PromptContext) -> str:
selected = context.state["landing_skills"]
Expand Down
7 changes: 4 additions & 3 deletions src/landing/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ def __init__(
self.active: dict[str, asyncio.Task[Action]] = {}
self.worker_task: asyncio.Task[None] | None = None

def create_agent(self) -> Agent:
agent = cast("Agent", BubAgent(self.framework, tools=self.tools, skill_dirs=()))
def create_agent(self) -> BubAgent:
agent = BubAgent(self.framework, tools=self.tools, skill_dirs=())
agent.name = "landing"
agent.max_steps = self.settings.max_steps
# Delegated text remains model input, even when it starts with Bub's command prefix.
agent.command_prefix = None
# Core `Agent` accepts None to disable commands; `BubAgent` narrows the annotation to str.
cast("Agent", agent).command_prefix = None
agent.system_prompt = system_prompt(self.settings)

@agent.system_prompt.add
Expand Down
29 changes: 25 additions & 4 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
from tests.conftest import completion


def load_tool(name):
"""Load a deferred MCP tool the way a model does before calling it."""
return completion(tool="tool_describe", arguments={"names": [name]})


def invoke_tool(name, arguments):
return completion(tool=name.replace(".", "_"), arguments=arguments)


@pytest.fixture
def mcp_server(tmp_path, monkeypatch):
monkeypatch.delenv("LANDING_MCP_CONFIG")
Expand Down Expand Up @@ -72,7 +81,8 @@ def test_cli_uses_selected_mcp_configuration(tmp_path, mcp_server, model, monkey
monkeypatch.setenv("LANDING_MCP_CONFIG", "custom.json")
responses, _ = model
responses.extend([
completion(tool="mcp_evidence_record", arguments={"text": "candidate"}),
load_tool("mcp.evidence_record"),
invoke_tool("mcp.evidence_record", {"text": "candidate"}),
completion("Recorded candidate."),
])
assert (
Expand Down Expand Up @@ -108,7 +118,11 @@ def test_mcp_tools_follow_mode_permissions(tmp_path, mcp_server, model, monkeypa
)
responses, _ = model
for text in ("denied", "allowed"):
responses.extend([completion(tool="mcp_evidence_record", arguments={"text": text}), completion(text)])
responses.extend([
load_tool("mcp.evidence_record"),
invoke_tool("mcp.evidence_record", {"text": text}),
completion(text),
])

async def run():
async with Runtime(tmp_path / "landing.sqlite3", workspaces={"default": tmp_path}).running() as landing:
Expand All @@ -129,7 +143,11 @@ def test_workspace_switch_does_not_reuse_previous_mcp_server(tmp_path, mcp_serve
configure(first / ".agents/mcp.json", receipt)
responses, _ = model
for text in ("first", "second"):
responses.extend([completion(tool="mcp_evidence_record", arguments={"text": text}), completion(text)])
responses.extend([
load_tool("mcp.evidence_record"),
invoke_tool("mcp.evidence_record", {"text": text}),
completion(text),
])

async def run():
async with Runtime(
Expand All @@ -150,7 +168,10 @@ def test_cancellation_closes_mcp_subprocess(tmp_path, mcp_server, model):
receipt = tmp_path / "record.txt"
configure(tmp_path / ".agents/mcp.json", receipt)
responses, _ = model
responses.append(completion(tool="mcp_evidence_record", arguments={"text": "pending", "wait": True}))
responses.extend([
load_tool("mcp.evidence_record"),
invoke_tool("mcp.evidence_record", {"text": "pending", "wait": True}),
])

async def run():
async with Runtime(tmp_path / "landing.sqlite3").running() as landing:
Expand Down
13 changes: 9 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading