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
43 changes: 42 additions & 1 deletion airbyte/cloud/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def __init__(
)
self._organization_info: dict[str, Any] | None = None
self._organization_info_fetch_failed: bool = False
self._email_lookup_attempted: bool = False

def _fetch_organization_info(self, *, force_refresh: bool = False) -> dict[str, Any]:
"""Fetch and cache organization info including billing status."""
Expand Down Expand Up @@ -101,7 +102,47 @@ def email(self) -> str | None:
if self._email is not None:
return self._email
info = self._fetch_organization_info()
return info.get("email")
email = info.get("email")
if isinstance(email, str):
return email
return self._lookup_email_in_org_listing()

@property
def email_domain(self) -> str | None:
"""Domain part of the organization's contact email, lowercased."""
email = self.email
if email is None or "@" not in email:
return None
return email.rsplit("@", 1)[1].lower()

def _lookup_email_in_org_listing(self) -> str | None:
"""Resolve the contact email via the organizations listing.

The `get_organization_info` Config API response does not include the contact
email, so organizations resolved by ID fall back to the public organizations
listing, which does carry it.
"""
if self._email_lookup_attempted:
return None
self._email_lookup_attempted = True
Comment thread
aaronsteers marked this conversation as resolved.
try:
organizations = api_util.list_organizations_for_user(
api_root=self._credentials.public_api_root,
client_id=self._credentials.client_id,
client_secret=self._credentials.client_secret,
bearer_token=self._credentials.bearer_token,
)
except Exception as ex:
logger.debug("Failed to resolve organization email via the org listing.", exc_info=ex)
return None
for organization in organizations:
if organization.organization_id == self.organization_id:
email = organization.email
if isinstance(email, str):
self._email = email
return email
return None
return None

def get_billing_status(self) -> CloudOrganizationBillingInfo:
"""Fetch billing status for the organization or raise on failure."""
Expand Down
13 changes: 11 additions & 2 deletions airbyte/mcp/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,13 @@ class CloudOrganizationResult(BaseModel):
name: str | None = None
"""Display name of the organization, when available."""
email: str | None = None
"""Email associated with the organization, when available."""
"""Email associated with the organization, when available.

Only returned by `describe_cloud_organization`; `list_cloud_organizations`
reports `email_domain` instead.
"""
email_domain: str | None = None
"""Domain of the organization's contact email (lowercased), when an email is known."""
enabled_features: list[OrganizationFeature]
"""Features enabled for this organization; see `OrganizationFeature`."""

Expand Down Expand Up @@ -2540,10 +2546,12 @@ def list_cloud_workspaces(
),
)

web_url_root = api_util.get_web_url_root(client.public_api_root)
results = [
CloudWorkspaceResult(
workspace_id=ws.workspace_id,
workspace_name=ws.name,
workspace_url=f"{web_url_root}/workspaces/{ws.workspace_id}",
organization_id=ws.organization_id,
)
for ws in workspaces
Expand Down Expand Up @@ -2819,7 +2827,7 @@ def list_cloud_organizations(
CloudOrganizationResult(
id=organization.organization_id,
name=organization.organization_name,
email=organization.email,
email_domain=organization.email_domain,
Comment thread
aaronsteers marked this conversation as resolved.
enabled_features=sorted(organization.enabled_features),
)
for organization in organizations
Expand Down Expand Up @@ -2919,6 +2927,7 @@ def describe_cloud_organization(
id=org.organization_id,
name=org.organization_name,
email=org.email,
email_domain=org.email_domain,
enabled_features=sorted(org.enabled_features),
)

Expand Down
97 changes: 97 additions & 0 deletions tests/unit_tests/test_cloud_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,6 +1688,99 @@ def get_organization(
assert result.name == "Organization"


def test_mcp_describe_cloud_organization_returns_email_and_domain(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`describe_cloud_organization` reports the contact email and its domain."""
_stub_organization_features(monkeypatch)

class DiscoveryClient:
def get_organization(self, **_: object) -> CloudOrganization:
return CloudOrganization(
organization_id="organization-id",
organization_name="Organization",
email="[email protected]",
)

monkeypatch.setattr(mcp_cloud, "_get_cloud_client", lambda _: DiscoveryClient())

result = mcp_cloud.describe_cloud_organization(
None, organization_id="organization-id", organization_name=None
)

assert result.email == "[email protected]"
assert result.email_domain == "example.com"


def test_mcp_list_cloud_organizations_hides_email_and_reports_domain(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`list_cloud_organizations` omits contact emails and reports only the domain."""
_stub_organization_features(monkeypatch)

class DiscoveryClient:
def list_organizations(self, **_: object) -> list[CloudOrganization]:
return [
CloudOrganization(
organization_id="organization-id",
organization_name="Organization",
email="[email protected]",
)
]

monkeypatch.setattr(mcp_cloud, "_get_cloud_client", lambda _: DiscoveryClient())

result = mcp_cloud.list_cloud_organizations(None)

assert result.organizations[0].email is None
assert result.organizations[0].email_domain == "example.com"


def test_cloud_organization_email_falls_back_to_org_listing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An org resolved via `get_organization_info` still resolves its email once."""
monkeypatch.setattr(
api_util,
"get_organization_info",
lambda **_: {"organizationId": "organization-id"},
)
list_calls: list[object] = []

class _OrganizationResponse:
organization_id = "organization-id"
email = "[email protected]"

monkeypatch.setattr(
api_util,
"list_organizations_for_user",
lambda **_: list_calls.append(_) or [_OrganizationResponse()],
)
organization = CloudOrganization("organization-id", bearer_token="token")

assert organization.email == "[email protected]"
assert organization.email == "[email protected]"
assert len(list_calls) == 1


def test_cloud_organization_email_domain() -> None:
"""`email_domain` is the lowercased domain of the contact email, or None."""
assert (
CloudOrganization(
organization_id="organization-id",
email="[email protected]",
).email_domain
== "sub.example.com"
)
assert (
CloudOrganization(
organization_id="organization-id",
email="not-an-email",
).email_domain
is None
)


def test_cloud_client_get_organization_uses_unbounded_organization_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down Expand Up @@ -2888,6 +2981,7 @@ def test_mcp_list_cloud_workspaces_discovery(

class DiscoveryClient:
organization_id: str | None = None
public_api_root: str = api_util.CLOUD_API_ROOT

def list_workspaces(
self, *, organization_id: str | None = None, **_: object
Expand Down Expand Up @@ -2921,6 +3015,9 @@ def get_organization(self, *, organization_id: str) -> CloudOrganization:
assert "permission" in (result.message or "")
else:
assert result.workspaces[0].workspace_id == "workspace-id"
assert result.workspaces[0].workspace_url == (
"https://cloud.airbyte.com/workspaces/workspace-id"
)
assert result.workspaces[1].organization_id is None
assert result.workspaces[0].organization_name == organization_name
assert result.workspaces[1].organization_name is None
Expand Down
1 change: 1 addition & 0 deletions tests/unit_tests/test_mcp_call_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def test_cloud_tool_records_resolved_organization(monkeypatch, events, tool):
organization_id=ORGANIZATION,
organization_name="Example",
email=None,
email_domain=None,
enabled_features=[],
get_billing_status=lambda: SimpleNamespace(
payment_status="okay",
Expand Down
1 change: 1 addition & 0 deletions tests/unit_tests/test_mcp_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ def test_describe_cloud_organization_excludes_billing_fields(
organization_id="org-id",
organization_name="Organization",
email="[email protected]",
email_domain="example.com",
enabled_features=[],
)
monkeypatch.setattr(
Expand Down
Loading