From 397f4372a626a94215938d3c09c44b01142abef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 00:17:53 +0000 Subject: [PATCH] Run provider setup in the background with a wait directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider setup (git clone + build, per-requirement pip install, and setup_commands such as `npx playwright install chrome`) previously ran synchronously and sequentially at import, before mcp.run() — so the MCP endpoint accepted no connections until the slowest provider finished installing. Now every provider's tools are registered up front as gated stubs built from the YAML alone, and the slow setup runs on a background daemon thread started in __main__. The MCP server (and UI) come up immediately; a tool whose provider is still installing returns a structured retry directive (`status: "initializing"`, `retry_after_seconds`) instead of failing or hanging, and the same registered tool transparently delegates to the real handler once setup completes. A provider whose handlers fail to build surfaces `status: "failed"` at call time. - New provider_status.py holds per-provider readiness state shared in-process (added to the Dockerfile COPY line; guarded by test_dockerfile.py). - Extracted build_tool_handlers() from register_provider() so the real handlers can be resolved on the background thread; register_provider and bootstrap_provider keep their existing behavior (synchronous opt-out path). - Knobs: MCPPROXY_BACKGROUND_SETUP (default 1) and MCPPROXY_INIT_RETRY_SECONDS (default 15). - Pin PIP_CACHE_DIR / UV_CACHE_DIR to the persisted /root/.cache volume. - Document the behavior in the README and add tests for the gated flow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RX32pG1y9CvXd2VXHPzM8q --- Dockerfile | 8 +- README.md | 56 ++++++- provider_status.py | 70 +++++++++ server.py | 345 ++++++++++++++++++++++++++++++++----------- tests/test_server.py | 133 +++++++++++++++++ 5 files changed, 525 insertions(+), 87 deletions(-) create mode 100644 provider_status.py 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 # ---------------------------------------------------------------------------