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
17 changes: 10 additions & 7 deletions src/opik_mcp/opik_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
47 changes: 35 additions & 12 deletions tests/test_opik_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading