diff --git a/src/opik_mcp/opik_client.py b/src/opik_mcp/opik_client.py index 26726ff..432a9bd 100644 --- a/src/opik_mcp/opik_client.py +++ b/src/opik_mcp/opik_client.py @@ -189,7 +189,7 @@ class OpikClient: def __init__( self, base_url: str, - api_key: str, + api_key: str | None, workspace: str | None, *, client: httpx.AsyncClient | None = None, @@ -512,10 +512,13 @@ async def list_prompt_versions( def _headers(self) -> dict[str, str]: headers = { - "Authorization": self._api_key, "Content-Type": "application/json", "Accept": "application/json", } + # Optional — self-hosted backends run with auth disabled and authorize on the + # workspace header alone, so only send Authorization when a key is configured. + if self._api_key: + headers["Authorization"] = self._api_key # Omitted for OAuth tokens — opik-backend derives the workspace from the token row if self._workspace: headers["Comet-Workspace"] = self._workspace @@ -619,7 +622,7 @@ async def write_json( return await http.request(method, url, content=content, headers=headers) -def resolve_opik_config(settings: Settings) -> tuple[str, str, str | None]: +def resolve_opik_config(settings: Settings) -> tuple[str, str | None, str | None]: """Resolve ``(opik_base_url, api_key, workspace)`` from settings or raise. Centralizes the rule for deriving Opik's REST base from either an explicit @@ -639,11 +642,11 @@ def resolve_opik_config(settings: Settings) -> tuple[str, str, str | None]: """ inbound_auth = inbound_authorization.get() inbound_ws = inbound_workspace.get() + # Optional: self-hosted backends run with auth disabled and authorize on the + # workspace header alone. When absent, requests go out without an Authorization + # header and the backend decides (a hosted Opik rejects with 401, surfaced as a + # normal request error). Mirrors the workspace handling just below. api_key = inbound_auth if inbound_auth else settings.opik_api_key - if not api_key: - raise MissingConfigError( - "OPIK_API_KEY (or an inbound Authorization header) is required to call Opik REST" - ) # OAuth access tokens carry their workspace server-side (opik-backend # derives it from the token row). Identified by token prefix — mirrors # the backend's McpOAuthTokenUtils.isMcpOAuthToken — so an API key that diff --git a/tests/test_opik_client.py b/tests/test_opik_client.py index 75b620d..967362d 100644 --- a/tests/test_opik_client.py +++ b/tests/test_opik_client.py @@ -328,13 +328,16 @@ def test_resolve_opik_config_rejects_empty_url_pair() -> None: resolve_opik_config(s) -def test_resolve_opik_config_requires_api_key() -> None: - from opik_mcp.config import MissingConfigError, Settings +def test_resolve_opik_config_allows_missing_api_key() -> None: + """The api key is optional — self-hosted backends run with auth disabled and + authorize on the workspace header alone, so a missing key no longer raises.""" + from opik_mcp.config import Settings from opik_mcp.opik_client import resolve_opik_config - s = Settings(opik_api_key=None, comet_workspace="ws") - with pytest.raises(MissingConfigError, match="OPIK_API_KEY"): - resolve_opik_config(s) + s = Settings(opik_api_key=None, comet_workspace="ws", opik_url="https://opik.example.com") + _base, api_key, workspace = resolve_opik_config(s) + assert api_key is None + assert workspace == "ws" def test_resolve_opik_config_defaults_workspace_when_unset() -> None: @@ -348,14 +351,34 @@ def test_resolve_opik_config_defaults_workspace_when_unset() -> None: assert workspace == DEFAULT_WORKSPACE -def test_resolve_opik_config_still_requires_api_key() -> None: - """The api key is still mandatory — only the workspace became optional.""" - from opik_mcp.config import MissingConfigError, Settings - from opik_mcp.opik_client import resolve_opik_config +def test_client_omits_authorization_header_when_no_api_key() -> None: + """Without a key, no Authorization header is sent — the workspace header alone + authorizes against an auth-disabled self-hosted backend.""" + from opik_mcp.opik_client import OpikClient - s = Settings(opik_api_key=None, comet_workspace="ws") - with pytest.raises(MissingConfigError, match="OPIK_API_KEY"): - resolve_opik_config(s) + client = OpikClient(base_url="https://opik.example.com", api_key=None, workspace="ws") + headers = client._headers() + assert "Authorization" not in headers + assert headers["Comet-Workspace"] == "ws" + + +@pytest.mark.anyio +async def test_no_api_key_against_authenticated_backend_surfaces_401() -> None: + """Regression: when a key IS required (hosted backend) but not configured, the + request is still attempted with no Authorization header and the backend's 401 + surfaces as a typed OpikAuthError — not a silent success, hang, or raw crash.""" + with respx.mock(base_url=OPIK_BASE) as mock: + route = mock.get("/v1/private/traces/tr-1").mock( + return_value=httpx.Response(401, json={"message": "unauthorized"}) + ) + client = OpikClient(base_url=OPIK_BASE, api_key=None, workspace="ws") + with pytest.raises(OpikAuthError): + await client.get_trace("tr-1") + + # The request went out (no client-side block) and carried no Authorization header. + assert route.called + sent = {k.lower() for k in route.calls.last.request.headers} + assert "authorization" not in sent def test_resolve_opik_config_oauth_token_makes_workspace_optional() -> None: