diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index a21653c..1d44a91 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -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. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cbf5eb0..50b5e6e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 231ef6c..c325c75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/landing/mcp.py b/src/landing/mcp.py index e2a81e3..2a18d7d 100644 --- a/src/landing/mcp.py +++ b/src/landing/mcp.py @@ -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 @@ -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() diff --git a/src/landing/prompts.py b/src/landing/prompts.py index b2902c8..810a6eb 100644 --- a/src/landing/prompts.py +++ b/src/landing/prompts.py @@ -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 @@ -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"] diff --git a/src/landing/runtime.py b/src/landing/runtime.py index 31ee003..9d1ea77 100644 --- a/src/landing/runtime.py +++ b/src/landing/runtime.py @@ -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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index cf87928..394d904 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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") @@ -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 ( @@ -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: @@ -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( @@ -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: diff --git a/uv.lock b/uv.lock index e4a7e98..4cf2486 100644 --- a/uv.lock +++ b/uv.lock @@ -276,15 +276,20 @@ app = [ [[package]] name = "bub-mcp" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "bub", extra = ["app"] }, { name = "fastmcp" }, + { name = "loguru" }, + { name = "mcp" }, + { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/57392a3274fc67f1bf52d65f4de3317081262a9525ebe4e9e7e2bf37f6b2/bub_mcp-0.3.0.tar.gz", hash = "sha256:429b2301de195848268d2768a58f38c7b78c4bb06e1ac70ba05b3a6f6abfb4ed", size = 8756, upload-time = "2026-09-30T12:02:02.969Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/65/138ca58b7321dbf4c54d490d9dc174637b0b724c1231bbbc487c80fbd7f7/bub_mcp-0.4.0.tar.gz", hash = "sha256:8cc58c6805cd7d11c1f7f9e77af1afbf83ef36e051e25285c1825721ec3ea642", size = 9691, upload-time = "2026-11-06T05:42:20.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/9b/ec9dcb508fa260f35512f45eeaeb3850d7218e58b404be595020e8178c0a/bub_mcp-0.3.0-py3-none-any.whl", hash = "sha256:68d5b8707d9b9e7bc7199855398257364615d349df25a86adee24d459db5c0b3", size = 10749, upload-time = "2026-09-30T12:02:02.092Z" }, + { url = "https://files.pythonhosted.org/packages/01/8d/f721617cd5fca7f95ace22d37804332a321d751293d0a44662fa3ea6cadb/bub_mcp-0.4.0-py3-none-any.whl", hash = "sha256:22920cf2a6e7047709f04b57640188d492f6fa1c6ef9cce2908703cdf9df86da", size = 11199, upload-time = "2026-11-06T05:42:19.023Z" }, ] [[package]] @@ -1082,7 +1087,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "bub", extras = ["app"], specifier = "==0.6.1" }, - { name = "bub-mcp", specifier = "==0.3.0" }, + { name = "bub-mcp", specifier = "==0.4.0" }, { name = "fastapi", specifier = ">=0.132" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pydantic-settings", specifier = ">=2.12" },