diff --git a/Dockerfile b/Dockerfile index 48010c8..76b2e2e 100755 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir uv ENV PATH="/root/.local/bin:$PATH" -COPY server.py config.py process_runner.py builtin_tools.py tool_registry.py rest_provider.py oauth_bootstrap.py catalog.py ./ +COPY server.py config.py process_runner.py builtin_tools.py tool_registry.py provider_status.py rest_provider.py oauth_bootstrap.py catalog.py ./ COPY frontend/ ./frontend/ COPY handlers/ ./handlers/ @@ -31,6 +31,12 @@ ENV MCPPROXY_FILES_DIR=/app/files ENV MCPPROXY_REPOS_DIR=/app/repos ENV MCPPROXY_REST_AUTH_DIR=/app/.rest-auth +# Pin the pip / uv wheel caches to the persisted /root/.cache volume +# (see docker-compose.yml) so ephemeral containers reuse downloaded wheels +# across restarts instead of re-fetching them every startup. +ENV PIP_CACHE_DIR=/root/.cache/pip +ENV UV_CACHE_DIR=/root/.cache/uv + EXPOSE 8888 8889 CMD ["python", "server.py"] diff --git a/README.md b/README.md index a228050..1d03dbd 100755 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ Each tool **provider** is a single YAML file under `tools/`. The YAML contains: runs `setup_commands`, then registers each tool automatically — no Python files to maintain separately, no changes to `server.py` needed when adding new tools. +By default this setup runs **in the background**: the MCP server starts accepting +requests immediately while each provider's dependencies install. A tool whose provider +is still installing returns a structured **retry directive** instead of blocking — see +[Non-blocking startup](#non-blocking-startup) below. + Two **built-in tools** (`mcpproxy__listfiles` and `mcpproxy__getfile`) are always registered without any YAML config. They give LLMs read-only access to a configurable directory (default: `/app/files`, mountable as a Docker volume) — useful for retrieving screenshots @@ -49,6 +54,46 @@ is added automatically when the tool is registered. | **8888** | MCP endpoint — `http://localhost:8888/mcp` | | **8889** | Web UI & OpenAI-compatible tools endpoint — `http://localhost:8889` | +## Non-blocking startup + +Provider setup — cloning/building repository providers, `pip install`-ing each +provider's `requirements`, and running its `setup_commands` (e.g. +`npx playwright install chrome`) — can take a long time. Rather than block the +MCP server until all of that finishes, mcpproxy **registers every tool up front +and runs the setup in the background**: + +- The MCP endpoint on `8888` and the UI on `8889` come up **immediately**. +- Every tool is advertised right away, so MCP clients see the full tool list at + once. +- A call to a tool whose provider is **still installing** returns a structured + retry directive instead of failing or hanging: + + ```json + { + "ok": false, + "tool": "playwright__browser_navigate", + "status": "initializing", + "retry_after_seconds": 15, + "message": "Tool 'playwright__browser_navigate' is not ready yet — provider 'playwright' is still installing its dependencies. Wait ~15s and call this tool again." + } + ``` + + The calling LLM reads the message and retries shortly. The two built-in tools + (`mcpproxy__listfiles` / `mcpproxy__getfile`) are always ready immediately. + +- If a provider's setup **fails**, its tools return `"status": "failed"` with the + error at call time (the rest of the server stays up). + +Knobs: + +| Variable | Default | Meaning | +|---|---|---| +| `MCPPROXY_BACKGROUND_SETUP` | `1` | Set to `0` to run setup synchronously before the server starts (the old blocking behaviour). | +| `MCPPROXY_INIT_RETRY_SECONDS` | `15` | Seconds advertised in the `retry_after_seconds` field of the directive. | + +Startup stays fast across restarts because pip/uv/npm caches and cloned repos are +persisted via Docker volumes — see [Volumes & caching](#volumes--caching). + ## Layout ```text @@ -201,9 +246,10 @@ Example — for a Playwright package provider: npx playwright install chrome ``` -Commands run in order before the server accepts connections. The subprocess package is -launched lazily on the first tool call, not at startup, so the browser binary is always -ready when needed. +Commands run in order in the background (see [Non-blocking startup](#non-blocking-startup)) — +the server accepts connections immediately and the provider's tools return a retry directive +until its setup finishes. The subprocess package itself is launched lazily on the first tool +call, so the browser binary is always ready when needed. > **After editing and saving** a provider's command or setup steps, click **Restart MCP Server** > (the yellow bar that appears after saving) to apply the changes. @@ -637,6 +683,10 @@ re-downloaded, re-built, or re-authorized on every fresh container. | `/root/.local/share/uv` | `mcpproxy-uv-tools` | uvx per-tool venvs | uvx re-creates per-tool venvs from cached wheels. | | `/app/.mcp-auth` | `mcpproxy-mcp-auth` | OAuth token cache (access + refresh tokens) for `mcp-remote` bridge providers, e.g. the official Asana MCP (`MCP_REMOTE_CONFIG_DIR`). Kept out of `/app/files` so tokens aren't exposed via `mcpproxy__getfile`. | Re-authorize through the browser on every fresh container. Only relevant if you run an OAuth-bridge provider. | +The image pins `PIP_CACHE_DIR=/root/.cache/pip` and `UV_CACHE_DIR=/root/.cache/uv` +so the pip and uv wheel caches always land inside the persisted `mcpproxy-cache` +volume, even if `HOME`/XDG defaults change. + In dev (`docker-compose.override.yml`), `mcpproxy-tools`, `mcpproxy-files`, `mcpproxy-repos`, and `mcpproxy-mcp-auth` are replaced with bind mounts (`./tools`, `./files`, `./repos`, `./.mcp-auth`) so you can inspect or edit them from the host diff --git a/provider_status.py b/provider_status.py new file mode 100644 index 0000000..c8b8f93 --- /dev/null +++ b/provider_status.py @@ -0,0 +1,70 @@ +""" +Shared provider-readiness registry — populated by server.py, read by both +server.py's gated tool handlers and frontend/app.py (for a "still initializing" +banner in the UI). + +Background: + server.py registers every provider's tools immediately as *gated stubs* + (built from the YAML tool list only — no pip/exec/network), then runs the + slow per-provider setup (pip install / build / setup_commands) on a + background thread. Until a provider's setup finishes, calls to its tools + return a "still initializing, retry shortly" directive instead of failing. + + This module holds the per-provider readiness state that the gated handlers + consult, plus the real handlers that replace the stubs once setup completes. + +Like tool_registry.py, every reader runs in the same Python process (the UI runs +in a daemon thread), so a plain module-level dict is sufficient — no IPC needed. +This module has NO import-time side effects so it is safe to import from anywhere. +""" + +from dataclasses import dataclass, field +from typing import Any, Callable + +# A provider that has been registered but whose dependencies are still +# installing. Once setup finishes it flips to "ready"; if building its real +# handlers fails it flips to "failed". +PENDING = "pending" +READY = "ready" +FAILED = "failed" + + +@dataclass +class ProviderState: + """Readiness of one provider and (once ready) its real tool handlers. + + ``name`` — the provider name (YAML filename stem), used in messages. + ``status`` — one of ``PENDING`` / ``READY`` / ``FAILED``. + ``error`` — failure detail when ``status == FAILED``, else ``None``. + ``handlers`` — advertised tool name → real async handler, populated when + the provider becomes ready. Gated stubs delegate here. + """ + + name: str + status: str = PENDING + error: str | None = None + handlers: dict[str, Callable[..., Any]] = field(default_factory=dict) + + +# provider name → ProviderState +_states: dict[str, ProviderState] = {} + + +def set_state(state: ProviderState) -> None: + """Register (or replace) a provider's readiness state.""" + _states[state.name] = state + + +def get(name: str) -> ProviderState | None: + """Return the state for ``name``, or ``None`` if unknown.""" + return _states.get(name) + + +def all_states() -> dict[str, ProviderState]: + """Return a shallow copy of the full provider → state map.""" + return dict(_states) + + +def clear() -> None: + """Remove all states. Used only in tests.""" + _states.clear() diff --git a/server.py b/server.py index a97328c..3a354e9 100755 --- a/server.py +++ b/server.py @@ -86,6 +86,20 @@ # long-running server start (e.g. `npm run start:dev`) into build_commands. BUILD_COMMAND_TIMEOUT = int(os.environ.get("MCPPROXY_BUILD_TIMEOUT", "600")) +# When enabled (default), provider setup (pip / build / setup_commands) runs on a +# background thread so the MCP server starts accepting requests immediately. +# Tools whose provider is still installing return a "still initializing, retry +# shortly" directive instead of failing. Set MCPPROXY_BACKGROUND_SETUP=0 to fall +# back to the old behaviour (setup runs synchronously before the server starts). +def _background_setup_enabled() -> bool: + return os.environ.get("MCPPROXY_BACKGROUND_SETUP", "1").strip().lower() not in ( + "0", "false", "no", "off", "" + ) + + +# Seconds advertised in the retry directive returned for a not-yet-ready tool. +INIT_RETRY_SECONDS = int(os.environ.get("MCPPROXY_INIT_RETRY_SECONDS", "15")) + # --------------------------------------------------------------------------- # Pure helpers @@ -410,6 +424,94 @@ def write_workdir_env_file(workdir: str, env_keys: list[str]) -> Path: return target +def build_tool_handlers( + spec: dict[str, Any], +) -> "dict[str, tuple[dict[str, Any], Callable[..., Any]]]": + """Build the real handler for every enabled tool in one provider spec. + + Returns an ordered mapping of advertised tool name + (``__``) → ``(tool_spec, handler)``. This is the + setup-dependent half of registration: it execs the code block for code + providers (so their declared ``requirements`` must already be installed) + and wires subprocess / REST handlers for the other provider kinds. + + Kept separate from registration so the gated-stub flow can register tools + up front (from the YAML alone) and resolve these real handlers later on a + background thread once setup has finished. + """ + source_path = spec.get("_config_path", "") + provider_name = Path(source_path).stem if source_path != "" else "" + rest_config = _get_rest_config(spec) + command = _get_package_command(spec) + # Repository providers piggy-back on the package code path; the only + # difference is that their subprocess is spawned with cwd= + # and env enriched with the repository.env_keys declared in YAML. + cwd = repository_workdir(provider_name, spec) + env_keys = list((spec.get("repository") or {}).get("env_keys") or []) + + handlers: dict[str, tuple[dict[str, Any], Callable[..., Any]]] = {} + + if rest_config is not None: + # ── REST provider ───────────────────────────────────────────────── + # Each tool maps 1:1 to an endpoint (matched by name). Endpoints are + # concrete by this point (OpenAPI specs are expanded into endpoints at + # create time by the frontend), so registration is network-free. + from rest_provider import _make_rest_handler + + endpoints = {e.get("name"): e for e in (rest_config.get("endpoints") or [])} + for tool_spec in spec.get("tools", []): + tool_name = tool_spec.get("name", "") + if not tool_is_enabled(tool_spec): + print(f"Skipping disabled tool: {advertised_tool_name(provider_name, tool_name)}") + continue + endpoint_spec = endpoints.get(tool_name) + if endpoint_spec is None: + raise ValueError( + f"REST tool '{tool_name}' in {source_path} has no matching " + f"endpoint (rest.endpoints[].name must equal the tool name)" + ) + handler = _make_rest_handler(endpoint_spec, rest_config, provider_name) + handlers[advertised_tool_name(provider_name, tool_name)] = (tool_spec, handler) + + elif command is not None: + # ── package provider (npx / uvx / python -m / any binary) ────────── + for tool_spec in spec.get("tools", []): + tool_name = tool_spec.get("name", "") + if not tool_is_enabled(tool_spec): + print(f"Skipping disabled tool: {advertised_tool_name(provider_name, tool_name)}") + continue + handler = _make_process_handler(command, tool_name, cwd=cwd, env_keys=env_keys) + handlers[advertised_tool_name(provider_name, tool_name)] = (tool_spec, handler) + + else: + # ── code provider ───────────────────────────────────────────────── + namespace = exec_provider_code(spec) + tools = spec.get("tools", []) + if not tools: + print(f"Warning: no tools declared in {source_path}") + return handlers + + for tool_spec in tools: + tool_name = tool_spec.get("name", "") + if not tool_is_enabled(tool_spec): + print(f"Skipping disabled tool: {advertised_tool_name(provider_name, tool_name)}") + continue + function_name = tool_spec.get("function") + if not function_name: + raise ValueError( + f"Tool '{tool_name}' in {source_path} is missing required 'function' field" + ) + handler = namespace.get(function_name) + if handler is None: + raise RuntimeError( + f"Function '{function_name}' (tool '{tool_name}') not found " + f"in the code block of {source_path}" + ) + handlers[advertised_tool_name(provider_name, tool_name)] = (tool_spec, handler) + + return handlers + + def register_provider(spec: dict[str, Any]) -> None: """Register all tools declared in one provider spec. @@ -420,88 +522,9 @@ def register_provider(spec: dict[str, Any]) -> None: they can be flipped back on without re-typing the schema. """ source_path = spec.get("_config_path", "") - provider_name = Path(source_path).stem if source_path != "" else "" try: - rest_config = _get_rest_config(spec) - command = _get_package_command(spec) - # Repository providers piggy-back on the package code path; the only - # difference is that their subprocess is spawned with cwd= - # and env enriched with the repository.env_keys declared in YAML. - cwd = repository_workdir(provider_name, spec) - env_keys = list((spec.get("repository") or {}).get("env_keys") or []) - - if rest_config is not None: - # ── REST provider ───────────────────────────────────────────────── - # Each tool maps 1:1 to an endpoint (matched by name). Endpoints are - # concrete by this point (OpenAPI specs are expanded into endpoints at - # create time by the frontend), so registration is network-free. - from rest_provider import _make_rest_handler - - endpoints = { - e.get("name"): e for e in (rest_config.get("endpoints") or []) - } - for tool_spec in spec.get("tools", []): - tool_name = tool_spec.get("name", "") - if not tool_is_enabled(tool_spec): - print(f"Skipping disabled tool: {advertised_tool_name(provider_name, tool_name)}") - continue - endpoint_spec = endpoints.get(tool_name) - if endpoint_spec is None: - raise ValueError( - f"REST tool '{tool_name}' in {source_path} has no matching " - f"endpoint (rest.endpoints[].name must equal the tool name)" - ) - handler = _make_rest_handler(endpoint_spec, rest_config, provider_name) - register_tool( - tool_spec, - handler, - advertised_name=advertised_tool_name(provider_name, tool_name), - ) - - elif command is not None: - # ── package provider (npx / uvx / python -m / any binary) ────────── - for tool_spec in spec.get("tools", []): - tool_name = tool_spec.get("name", "") - if not tool_is_enabled(tool_spec): - print(f"Skipping disabled tool: {advertised_tool_name(provider_name, tool_name)}") - continue - handler = _make_process_handler(command, tool_name, cwd=cwd, env_keys=env_keys) - register_tool( - tool_spec, - handler, - advertised_name=advertised_tool_name(provider_name, tool_name), - ) - - else: - # ── code provider ───────────────────────────────────────────────── - namespace = exec_provider_code(spec) - tools = spec.get("tools", []) - if not tools: - print(f"Warning: no tools declared in {source_path}") - return - - for tool_spec in tools: - tool_name = tool_spec.get("name", "") - if not tool_is_enabled(tool_spec): - print(f"Skipping disabled tool: {advertised_tool_name(provider_name, tool_name)}") - continue - function_name = tool_spec.get("function") - if not function_name: - raise ValueError( - f"Tool '{tool_name}' in {source_path} is missing required 'function' field" - ) - handler = namespace.get(function_name) - if handler is None: - raise RuntimeError( - f"Function '{function_name}' (tool '{tool_name}') not found " - f"in the code block of {source_path}" - ) - register_tool( - tool_spec, - handler, - advertised_name=advertised_tool_name(provider_name, tool_name), - ) - + for advertised_name, (tool_spec, handler) in build_tool_handlers(spec).items(): + register_tool(tool_spec, handler, advertised_name=advertised_name) except Exception as exc: print(f"register_provider error in {source_path}: {exc}") traceback.print_exc() @@ -682,8 +705,149 @@ def bootstrap_provider(provider_spec: dict[str, Any]) -> None: traceback.print_exc() -for provider_spec in load_provider_specs(CONFIG_DIR): - bootstrap_provider(provider_spec) +# --------------------------------------------------------------------------- +# Background (non-blocking) startup +# --------------------------------------------------------------------------- +# +# With background setup enabled (the default) every provider's tools are +# registered immediately as *gated stubs* built from the YAML alone, then the +# slow setup (pip / build / setup_commands) runs on a background thread. A tool +# called while its provider is still installing returns a retry directive +# instead of failing; once setup finishes the same registered tool transparently +# delegates to the provider's real handler. + +import provider_status + + +def register_gated_provider(spec: dict[str, Any], state: provider_status.ProviderState) -> None: + """Register every enabled tool of ``spec`` as a stub gated on ``state``. + + The stub is built from the YAML tool list only (no pip/exec/network), so it + is safe to call before the provider's dependencies are installed. Each + stub's handler consults ``state``: while ``PENDING`` it returns a retry + directive, when ``READY`` it delegates to ``state.handlers[advertised_name]``, + and when ``FAILED`` it surfaces the setup error. + """ + source_path = spec.get("_config_path", "") + provider_name = Path(source_path).stem if source_path != "" else "" + try: + for tool_spec in spec.get("tools", []): + tool_name = tool_spec.get("name", "") + advertised_name = advertised_tool_name(provider_name, tool_name) + if not tool_is_enabled(tool_spec): + print(f"Skipping disabled tool: {advertised_name}") + continue + register_tool( + tool_spec, + _make_gate_handler(state, advertised_name), + advertised_name=advertised_name, + ) + except Exception as exc: + print(f"register_gated_provider error in {source_path}: {exc}") + traceback.print_exc() + + +def _make_gate_handler( + state: provider_status.ProviderState, advertised_name: str +) -> Callable[..., Any]: + """Return a handler that dispatches based on ``state.status``.""" + + async def gate(context: dict[str, Any], **kwargs: Any) -> Any: + if state.status == provider_status.READY: + handler = state.handlers.get(advertised_name) + if handler is None: + return { + "ok": False, + "tool": advertised_name, + "error": f"Provider '{state.name}' is ready but tool '{advertised_name}' has no handler.", + } + return await handler(context=context, **kwargs) + if state.status == provider_status.FAILED: + return { + "ok": False, + "tool": advertised_name, + "status": "failed", + "error": f"Provider '{state.name}' failed to initialize: {state.error}", + } + # PENDING — setup still running in the background. + return { + "ok": False, + "tool": advertised_name, + "status": "initializing", + "retry_after_seconds": INIT_RETRY_SECONDS, + "message": ( + f"Tool '{advertised_name}' is not ready yet — provider " + f"'{state.name}' is still installing its dependencies. " + f"Wait ~{INIT_RETRY_SECONDS}s and call this tool again." + ), + } + + gate.__name__ = advertised_name + return gate + + +def _resolve_provider(spec: dict[str, Any], state: provider_status.ProviderState) -> None: + """Run a provider's setup then build its real handlers, flipping ``state``. + + Mirrors ``bootstrap_provider`` semantics: a setup failure is logged but does + not stop handler construction (package/REST handlers don't need setup, and a + code provider may still import already-present modules). Only a failure to + build the handlers themselves marks the provider ``FAILED``. + """ + source_path = spec.get("_config_path", "") + try: + run_provider_setup(spec) + except Exception as exc: + print( + f"Provider {source_path}: setup failed ({exc}). " + "Tools are registered but may not work until the " + "build / requirements / setup_commands are fixed (see the editor)." + ) + traceback.print_exc() + try: + state.handlers = { + name: handler for name, (_spec, handler) in build_tool_handlers(spec).items() + } + state.status = provider_status.READY + print(f"Provider '{state.name}' ready ({len(state.handlers)} tool(s)).") + except Exception as exc: + state.status = provider_status.FAILED + state.error = str(exc) + print(f"Provider '{state.name}' failed to initialize: {exc}") + traceback.print_exc() + + +def _background_bootstrap( + specs: list[dict[str, Any]], + states: dict[str, provider_status.ProviderState], +) -> None: + """Resolve every provider sequentially off the request path.""" + for spec in specs: + source_path = spec.get("_config_path", "") + provider_name = Path(source_path).stem if source_path != "" else "" + state = states.get(provider_name) + if state is None: + continue + _resolve_provider(spec, state) + + +# Specs whose background setup __main__ kicks off after the server is listening. +_PENDING_SPECS: list[dict[str, Any]] = [] +_PENDING_STATES: dict[str, provider_status.ProviderState] = {} + +if _background_setup_enabled(): + for provider_spec in load_provider_specs(CONFIG_DIR): + _source_path = provider_spec.get("_config_path", "") + _provider_name = Path(_source_path).stem if _source_path != "" else "" + _state = provider_status.ProviderState(name=_provider_name) + provider_status.set_state(_state) + register_gated_provider(provider_spec, _state) + _PENDING_SPECS.append(provider_spec) + _PENDING_STATES[_provider_name] = _state +else: + # Opt-out: original synchronous behaviour (setup blocks server startup). + for provider_spec in load_provider_specs(CONFIG_DIR): + bootstrap_provider(provider_spec) # --------------------------------------------------------------------------- @@ -850,6 +1014,21 @@ def _run_ui() -> None: ui_thread = threading.Thread(target=_run_ui, daemon=True, name="ui-server") ui_thread.start() print(f"UI server starting on http://{UI_HOST}:{UI_PORT}") + if _PENDING_SPECS: + # Run provider setup (pip / build / setup_commands) in the background + # so the MCP server below starts serving immediately. Until each + # provider is ready its tools return a retry directive. + bootstrap_thread = threading.Thread( + target=_background_bootstrap, + args=(_PENDING_SPECS, _PENDING_STATES), + daemon=True, + name="provider-bootstrap", + ) + bootstrap_thread.start() + print( + f"Installing dependencies for {len(_PENDING_SPECS)} provider(s) " + "in the background; tools return a retry directive until ready." + ) if _warm_remote_enabled(): warm_thread = threading.Thread( target=_warm_remote_providers, daemon=True, name="remote-warmup" diff --git a/tests/test_server.py b/tests/test_server.py index e9066be..4cc1e70 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1145,6 +1145,139 @@ def test_timeout_constant_overridable_via_env(self, monkeypatch): assert srv.BUILD_COMMAND_TIMEOUT > 0 +# --------------------------------------------------------------------------- +# Gated (non-blocking) startup — stubs, retry directive, background resolution +# --------------------------------------------------------------------------- + +import provider_status +from server import ( + _make_gate_handler, + _resolve_provider, + register_gated_provider, +) + + +class TestRegisterGatedProvider: + def _spec(self, tmp_path): + return { + "_config_path": str(tmp_path / "demo.yaml"), + "code": "async def ping(context):\n return {'ok': True}\n", + "tools": [ + { + "name": "ping", "function": "ping", "description": "x", + "input_schema": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "off", "function": "off", "description": "x", "enabled": False, + "input_schema": {"type": "object", "properties": {}, "required": []}, + }, + ], + } + + def test_registers_stubs_without_running_setup_or_code(self, tmp_path: Path): + names: list[str] = [] + + def fake_decorator(**kwargs): + names.append(kwargs.get("name")) + return lambda fn: fn + + state = provider_status.ProviderState(name="demo") + with patch("server.mcp") as mock_mcp, \ + patch("server.run_provider_setup") as mock_setup, \ + patch("server.exec_provider_code") as mock_exec: + mock_mcp.tool.side_effect = fake_decorator + register_gated_provider(self._spec(tmp_path), state) + + # Only the enabled tool is registered, and neither setup nor the code + # block was executed (stubs come from the YAML alone). + assert names == ["demo__ping"] + mock_setup.assert_not_called() + mock_exec.assert_not_called() + + +class TestGateHandler: + @pytest.mark.asyncio + async def test_pending_returns_retry_directive(self): + state = provider_status.ProviderState(name="demo") # PENDING by default + gate = _make_gate_handler(state, "demo__ping") + result = await gate(context={}) + assert result["ok"] is False + assert result["status"] == "initializing" + assert isinstance(result["retry_after_seconds"], int) + assert "demo" in result["message"] + + @pytest.mark.asyncio + async def test_failed_returns_error(self): + state = provider_status.ProviderState( + name="demo", status=provider_status.FAILED, error="pip exploded" + ) + gate = _make_gate_handler(state, "demo__ping") + result = await gate(context={}) + assert result["ok"] is False + assert result["status"] == "failed" + assert "pip exploded" in result["error"] + + @pytest.mark.asyncio + async def test_ready_delegates_to_real_handler(self): + calls = [] + + async def real(context, **kwargs): + calls.append((context, kwargs)) + return {"ok": True, "v": 1} + + state = provider_status.ProviderState(name="demo", status=provider_status.READY) + state.handlers = {"demo__ping": real} + gate = _make_gate_handler(state, "demo__ping") + result = await gate(context={"a": 1}, msg="hi") + assert result == {"ok": True, "v": 1} + assert calls[0][1] == {"msg": "hi"} + + +class TestResolveProvider: + def _spec(self, tmp_path): + return { + "_config_path": str(tmp_path / "demo.yaml"), + "code": "async def ping(context):\n return {'ok': True}\n", + "tools": [{ + "name": "ping", "function": "ping", "description": "x", + "input_schema": {"type": "object", "properties": {}, "required": []}, + }], + } + + def test_pending_flips_to_ready_with_handlers(self, tmp_path: Path): + state = provider_status.ProviderState(name="demo") + with patch("server.run_provider_setup") as mock_setup: + _resolve_provider(self._spec(tmp_path), state) + mock_setup.assert_called_once() + assert state.status == provider_status.READY + assert "demo__ping" in state.handlers + assert callable(state.handlers["demo__ping"]) + + def test_setup_failure_still_builds_handlers(self, tmp_path: Path): + state = provider_status.ProviderState(name="demo") + with patch("server.run_provider_setup", side_effect=RuntimeError("pip failed")): + _resolve_provider(self._spec(tmp_path), state) + # Setup failure is non-fatal: handlers still built, provider ready. + assert state.status == provider_status.READY + assert "demo__ping" in state.handlers + + def test_handler_build_failure_marks_failed(self, tmp_path: Path): + # Code block that references a missing function → build_tool_handlers raises. + spec = { + "_config_path": str(tmp_path / "demo.yaml"), + "code": "x = 1\n", + "tools": [{ + "name": "ping", "function": "missing", "description": "x", + "input_schema": {"type": "object", "properties": {}, "required": []}, + }], + } + state = provider_status.ProviderState(name="demo") + with patch("server.run_provider_setup"): + _resolve_provider(spec, state) + assert state.status == provider_status.FAILED + assert state.error + + # --------------------------------------------------------------------------- # bootstrap_provider — setup runs before registration # ---------------------------------------------------------------------------