From 6b28e79c2d688cd6317ce0544ea83e70b1f91bed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:39:34 +0000 Subject: [PATCH 1/2] fix(mcp): hide org emails in list results and fill workspace_url list_cloud_organizations no longer returns full contact emails for every organization; it reports email_domain instead, while describe_cloud_organization returns both email and email_domain. describe_cloud_organization previously returned email=None because _get_organization_by_id uses the get_organization_info Config API, whose response does not include the contact email. CloudOrganization.email now falls back to the public organizations listing (one extra call, cached). list_cloud_workspaces now fills workspace_url the same way describe_cloud_workspace does. Co-Authored-By: AJ Steers --- airbyte/cloud/organizations.py | 43 +++++++++- airbyte/mcp/cloud.py | 13 ++- tests/unit_tests/test_cloud_credentials.py | 97 ++++++++++++++++++++++ tests/unit_tests/test_mcp_cloud.py | 1 + 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/airbyte/cloud/organizations.py b/airbyte/cloud/organizations.py index 1b4efc437..a658d7620 100644 --- a/airbyte/cloud/organizations.py +++ b/airbyte/cloud/organizations.py @@ -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.""" @@ -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 + 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.""" diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 05124c5f2..ba32eee74 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -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`.""" @@ -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 @@ -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, enabled_features=sorted(organization.enabled_features), ) for organization in organizations @@ -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), ) diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index 28b4d7bc4..c43cf121c 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -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="Admin@Example.COM", + ) + + 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 == "Admin@Example.COM" + 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="Admin@Example.COM", + ) + ] + + 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 = "listed@example.com" + + monkeypatch.setattr( + api_util, + "list_organizations_for_user", + lambda **_: list_calls.append(_) or [_OrganizationResponse()], + ) + organization = CloudOrganization("organization-id", bearer_token="token") + + assert organization.email == "listed@example.com" + assert organization.email == "listed@example.com" + 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="User@Sub.Example.COM", + ).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: @@ -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 @@ -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 diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 45536142f..8f38ef1e2 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -725,6 +725,7 @@ def test_describe_cloud_organization_excludes_billing_fields( organization_id="org-id", organization_name="Organization", email="org@example.com", + email_domain="example.com", enabled_features=[], ) monkeypatch.setattr( From 9a85db909993416c063fb3a923d13eda44c9d629 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:59:27 +0000 Subject: [PATCH 2/2] test(mcp): include email_domain in the describe_cloud_organization fixture Co-Authored-By: AJ Steers --- tests/unit_tests/test_mcp_call_scope.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit_tests/test_mcp_call_scope.py b/tests/unit_tests/test_mcp_call_scope.py index ef1addb35..610d8bb83 100644 --- a/tests/unit_tests/test_mcp_call_scope.py +++ b/tests/unit_tests/test_mcp_call_scope.py @@ -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",