|
8 | 8 | from pydantic import BaseModel, Field |
9 | 9 |
|
10 | 10 | from airbyte import cloud, get_destination, get_source |
11 | | -from airbyte._util.api_util import PyAirbyteInputError |
| 11 | +from airbyte._util import api_util |
12 | 12 | from airbyte.cloud.auth import ( |
13 | 13 | resolve_cloud_api_url, |
14 | 14 | resolve_cloud_client_id, |
|
18 | 18 | from airbyte.cloud.connectors import CustomCloudSourceDefinition |
19 | 19 | from airbyte.cloud.workspaces import CloudWorkspace |
20 | 20 | from airbyte.destinations.util import get_noop_destination |
| 21 | +from airbyte.exceptions import AirbyteMissingResourceError, PyAirbyteInputError |
21 | 22 | from airbyte.mcp._tool_utils import ( |
22 | 23 | check_guid_created_in_session, |
23 | 24 | mcp_tool, |
24 | 25 | register_guid_created_in_session, |
25 | 26 | register_tools, |
26 | 27 | ) |
27 | 28 | from airbyte.mcp._util import resolve_config, resolve_list_of_strings |
| 29 | +from airbyte.secrets import SecretString |
28 | 30 |
|
29 | 31 |
|
30 | 32 | CLOUD_AUTH_TIP_TEXT = ( |
@@ -121,6 +123,28 @@ class CloudConnectionDetails(BaseModel): |
121 | 123 | """Table prefix applied when syncing to the destination.""" |
122 | 124 |
|
123 | 125 |
|
| 126 | +class CloudOrganizationResult(BaseModel): |
| 127 | + """Information about an organization in Airbyte Cloud.""" |
| 128 | + |
| 129 | + id: str |
| 130 | + """The organization ID.""" |
| 131 | + name: str |
| 132 | + """Display name of the organization.""" |
| 133 | + email: str |
| 134 | + """Email associated with the organization.""" |
| 135 | + |
| 136 | + |
| 137 | +class CloudWorkspaceResult(BaseModel): |
| 138 | + """Information about a workspace in Airbyte Cloud.""" |
| 139 | + |
| 140 | + id: str |
| 141 | + """The workspace ID.""" |
| 142 | + name: str |
| 143 | + """Display name of the workspace.""" |
| 144 | + organization_id: str |
| 145 | + """ID of the organization this workspace belongs to.""" |
| 146 | + |
| 147 | + |
124 | 148 | def _get_cloud_workspace(workspace_id: str | None = None) -> CloudWorkspace: |
125 | 149 | """Get an authenticated CloudWorkspace. |
126 | 150 |
|
@@ -852,6 +876,226 @@ def list_deployed_cloud_connections( |
852 | 876 | ] |
853 | 877 |
|
854 | 878 |
|
| 879 | +def _resolve_organization( |
| 880 | + organization_id: str | None, |
| 881 | + organization_name: str | None, |
| 882 | + *, |
| 883 | + api_root: str, |
| 884 | + client_id: SecretString, |
| 885 | + client_secret: SecretString, |
| 886 | +) -> api_util.models.OrganizationResponse: |
| 887 | + """Resolve organization from either ID or exact name match. |
| 888 | +
|
| 889 | + Args: |
| 890 | + organization_id: The organization ID (if provided directly) |
| 891 | + organization_name: The organization name (exact match required) |
| 892 | + api_root: The API root URL |
| 893 | + client_id: OAuth client ID |
| 894 | + client_secret: OAuth client secret |
| 895 | +
|
| 896 | + Returns: |
| 897 | + The resolved OrganizationResponse object |
| 898 | +
|
| 899 | + Raises: |
| 900 | + PyAirbyteInputError: If neither or both parameters are provided, |
| 901 | + or if no organization matches the exact name |
| 902 | + AirbyteMissingResourceError: If the organization is not found |
| 903 | + """ |
| 904 | + if organization_id and organization_name: |
| 905 | + raise PyAirbyteInputError( |
| 906 | + message="Provide either 'organization_id' or 'organization_name', not both." |
| 907 | + ) |
| 908 | + if not organization_id and not organization_name: |
| 909 | + raise PyAirbyteInputError( |
| 910 | + message="Either 'organization_id' or 'organization_name' must be provided." |
| 911 | + ) |
| 912 | + |
| 913 | + # Get all organizations for the user |
| 914 | + orgs = api_util.list_organizations_for_user( |
| 915 | + api_root=api_root, |
| 916 | + client_id=client_id, |
| 917 | + client_secret=client_secret, |
| 918 | + ) |
| 919 | + |
| 920 | + if organization_id: |
| 921 | + # Find by ID |
| 922 | + matching_orgs = [org for org in orgs if org.organization_id == organization_id] |
| 923 | + if not matching_orgs: |
| 924 | + raise AirbyteMissingResourceError( |
| 925 | + resource_type="organization", |
| 926 | + context={ |
| 927 | + "organization_id": organization_id, |
| 928 | + "message": f"No organization found with ID '{organization_id}' " |
| 929 | + "for the current user.", |
| 930 | + }, |
| 931 | + ) |
| 932 | + return matching_orgs[0] |
| 933 | + |
| 934 | + # Find by exact name match (case-sensitive) |
| 935 | + matching_orgs = [org for org in orgs if org.organization_name == organization_name] |
| 936 | + |
| 937 | + if not matching_orgs: |
| 938 | + raise AirbyteMissingResourceError( |
| 939 | + resource_type="organization", |
| 940 | + context={ |
| 941 | + "organization_name": organization_name, |
| 942 | + "message": f"No organization found with exact name '{organization_name}' " |
| 943 | + "for the current user.", |
| 944 | + }, |
| 945 | + ) |
| 946 | + |
| 947 | + if len(matching_orgs) > 1: |
| 948 | + raise PyAirbyteInputError( |
| 949 | + message=f"Multiple organizations found with name '{organization_name}'. " |
| 950 | + "Please use 'organization_id' instead to specify the exact organization." |
| 951 | + ) |
| 952 | + |
| 953 | + return matching_orgs[0] |
| 954 | + |
| 955 | + |
| 956 | +def _resolve_organization_id( |
| 957 | + organization_id: str | None, |
| 958 | + organization_name: str | None, |
| 959 | + *, |
| 960 | + api_root: str, |
| 961 | + client_id: SecretString, |
| 962 | + client_secret: SecretString, |
| 963 | +) -> str: |
| 964 | + """Resolve organization ID from either ID or exact name match. |
| 965 | +
|
| 966 | + This is a convenience wrapper around _resolve_organization that returns just the ID. |
| 967 | + """ |
| 968 | + org = _resolve_organization( |
| 969 | + organization_id=organization_id, |
| 970 | + organization_name=organization_name, |
| 971 | + api_root=api_root, |
| 972 | + client_id=client_id, |
| 973 | + client_secret=client_secret, |
| 974 | + ) |
| 975 | + return org.organization_id |
| 976 | + |
| 977 | + |
| 978 | +@mcp_tool( |
| 979 | + domain="cloud", |
| 980 | + read_only=True, |
| 981 | + idempotent=True, |
| 982 | + open_world=True, |
| 983 | + extra_help_text=CLOUD_AUTH_TIP_TEXT, |
| 984 | +) |
| 985 | +def list_cloud_workspaces( |
| 986 | + *, |
| 987 | + organization_id: Annotated[ |
| 988 | + str | None, |
| 989 | + Field( |
| 990 | + description="Organization ID. Required if organization_name is not provided.", |
| 991 | + default=None, |
| 992 | + ), |
| 993 | + ], |
| 994 | + organization_name: Annotated[ |
| 995 | + str | None, |
| 996 | + Field( |
| 997 | + description=( |
| 998 | + "Organization name (exact match). " "Required if organization_id is not provided." |
| 999 | + ), |
| 1000 | + default=None, |
| 1001 | + ), |
| 1002 | + ], |
| 1003 | + name_contains: Annotated[ |
| 1004 | + str | None, |
| 1005 | + "Optional substring to filter workspaces by name (server-side filtering)", |
| 1006 | + ] = None, |
| 1007 | + max_items_limit: Annotated[ |
| 1008 | + int | None, |
| 1009 | + "Optional maximum number of items to return (default: no limit)", |
| 1010 | + ] = None, |
| 1011 | +) -> list[CloudWorkspaceResult]: |
| 1012 | + """List all workspaces in a specific organization. |
| 1013 | +
|
| 1014 | + Requires either organization_id OR organization_name (exact match) to be provided. |
| 1015 | + This tool will NOT list workspaces across all organizations - you must specify |
| 1016 | + which organization to list workspaces from. |
| 1017 | + """ |
| 1018 | + api_root = resolve_cloud_api_url() |
| 1019 | + client_id = resolve_cloud_client_id() |
| 1020 | + client_secret = resolve_cloud_client_secret() |
| 1021 | + |
| 1022 | + resolved_org_id = _resolve_organization_id( |
| 1023 | + organization_id=organization_id, |
| 1024 | + organization_name=organization_name, |
| 1025 | + api_root=api_root, |
| 1026 | + client_id=client_id, |
| 1027 | + client_secret=client_secret, |
| 1028 | + ) |
| 1029 | + |
| 1030 | + workspaces = api_util.list_workspaces_in_organization( |
| 1031 | + organization_id=resolved_org_id, |
| 1032 | + api_root=api_root, |
| 1033 | + client_id=client_id, |
| 1034 | + client_secret=client_secret, |
| 1035 | + name_contains=name_contains, |
| 1036 | + max_items_limit=max_items_limit, |
| 1037 | + ) |
| 1038 | + |
| 1039 | + return [ |
| 1040 | + CloudWorkspaceResult( |
| 1041 | + id=ws.get("workspaceId", ""), |
| 1042 | + name=ws.get("name", ""), |
| 1043 | + organization_id=ws.get("organizationId", ""), |
| 1044 | + ) |
| 1045 | + for ws in workspaces |
| 1046 | + ] |
| 1047 | + |
| 1048 | + |
| 1049 | +@mcp_tool( |
| 1050 | + domain="cloud", |
| 1051 | + read_only=True, |
| 1052 | + idempotent=True, |
| 1053 | + open_world=True, |
| 1054 | + extra_help_text=CLOUD_AUTH_TIP_TEXT, |
| 1055 | +) |
| 1056 | +def describe_cloud_organization( |
| 1057 | + *, |
| 1058 | + organization_id: Annotated[ |
| 1059 | + str | None, |
| 1060 | + Field( |
| 1061 | + description="Organization ID. Required if organization_name is not provided.", |
| 1062 | + default=None, |
| 1063 | + ), |
| 1064 | + ], |
| 1065 | + organization_name: Annotated[ |
| 1066 | + str | None, |
| 1067 | + Field( |
| 1068 | + description=( |
| 1069 | + "Organization name (exact match). " "Required if organization_id is not provided." |
| 1070 | + ), |
| 1071 | + default=None, |
| 1072 | + ), |
| 1073 | + ], |
| 1074 | +) -> CloudOrganizationResult: |
| 1075 | + """Get details about a specific organization. |
| 1076 | +
|
| 1077 | + Requires either organization_id OR organization_name (exact match) to be provided. |
| 1078 | + This tool is useful for looking up an organization's ID from its name, or vice versa. |
| 1079 | + """ |
| 1080 | + api_root = resolve_cloud_api_url() |
| 1081 | + client_id = resolve_cloud_client_id() |
| 1082 | + client_secret = resolve_cloud_client_secret() |
| 1083 | + |
| 1084 | + org = _resolve_organization( |
| 1085 | + organization_id=organization_id, |
| 1086 | + organization_name=organization_name, |
| 1087 | + api_root=api_root, |
| 1088 | + client_id=client_id, |
| 1089 | + client_secret=client_secret, |
| 1090 | + ) |
| 1091 | + |
| 1092 | + return CloudOrganizationResult( |
| 1093 | + id=org.organization_id, |
| 1094 | + name=org.organization_name, |
| 1095 | + email=org.email, |
| 1096 | + ) |
| 1097 | + |
| 1098 | + |
855 | 1099 | def _get_custom_source_definition_description( |
856 | 1100 | custom_source: CustomCloudSourceDefinition, |
857 | 1101 | ) -> str: |
|
0 commit comments