-
Notifications
You must be signed in to change notification settings - Fork 74
feat(mcp): let agents discover their own Cloud org and workspace IDs #1094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
| # tool / helper definitions as a redundant "API Documentation" list. | ||
| __all__: list[str] = [] | ||
|
|
||
| from http import HTTPStatus | ||
| from pathlib import Path | ||
| from typing import Annotated, Any, Literal, cast | ||
|
|
||
|
|
@@ -42,6 +43,7 @@ | |
| ) | ||
| from airbyte.destinations.util import get_noop_destination | ||
| from airbyte.exceptions import ( | ||
| AirbyteError, | ||
| AirbyteMissingResourceError, | ||
| AirbyteMissingWorkspaceContextError, | ||
| PyAirbyteInputError, | ||
|
|
@@ -57,12 +59,13 @@ | |
| CLOUD_AUTH_TIP_TEXT = ( | ||
| f"When connecting to a hosted MCP server, provide a bearer token via the " | ||
| f"`{MCP_BEARER_TOKEN_HEADER}` header, or client credentials via the " | ||
| f"`{MCP_CLIENT_ID_HEADER}` and `{MCP_CLIENT_SECRET_HEADER}` headers, plus " | ||
| f"the workspace ID via the `{MCP_WORKSPACE_ID_HEADER}` header. For local or " | ||
| f"`{MCP_CLIENT_ID_HEADER}` and `{MCP_CLIENT_SECRET_HEADER}` headers. To discover " | ||
| f"available organizations and workspaces, call `list_cloud_organizations` and " | ||
| f"`list_cloud_workspaces` before asking the user for an ID. For local or " | ||
| f"stdio connections, set the `{CLOUD_BEARER_TOKEN_ENV_VAR}` environment " | ||
| f"variable, or both `{CLOUD_CLIENT_ID_ENV_VAR}` and " | ||
| f"`{CLOUD_CLIENT_SECRET_ENV_VAR}`, plus `{CLOUD_WORKSPACE_ID_ENV_VAR}` for the " | ||
| f"workspace." | ||
| f"`{CLOUD_CLIENT_SECRET_ENV_VAR}`. If discovery returns multiple candidates, " | ||
| f"ask the user to choose one; do not select automatically." | ||
| ) | ||
| WORKSPACE_ID_TIP_TEXT = ( | ||
| f"Workspace ID. Hosted MCP connections pass it via the " | ||
|
|
@@ -192,6 +195,16 @@ class CloudOrganizationResult(BaseModel): | |
| Defaults to False unless we have affirmative evidence of a locked state.""" | ||
|
|
||
|
|
||
| class CloudOrganizationListResult(BaseModel): | ||
| """Result of discovering organizations in Airbyte Cloud.""" | ||
|
|
||
| organizations: list[CloudOrganizationResult] | ||
| """Organizations visible to the authenticated credentials.""" | ||
|
|
||
| message: str | None = None | ||
| """Additional guidance when discovery returns no results or is unavailable.""" | ||
|
|
||
|
|
||
| class CloudWorkspaceResult(BaseModel): | ||
| """Information about a workspace in Airbyte Cloud.""" | ||
|
|
||
|
|
@@ -219,6 +232,16 @@ class CloudWorkspaceResult(BaseModel): | |
| Requires ORGANIZATION_READER permission.""" | ||
|
|
||
|
|
||
| class CloudWorkspaceListResult(BaseModel): | ||
| """Result of discovering workspaces in Airbyte Cloud.""" | ||
|
|
||
| workspaces: list[CloudWorkspaceResult] | ||
| """Workspaces visible to the authenticated credentials.""" | ||
|
|
||
| message: str | None = None | ||
| """Additional guidance when discovery returns no results.""" | ||
|
|
||
|
|
||
| class LogReadResult(BaseModel): | ||
| """Result of reading sync logs with pagination support.""" | ||
|
|
||
|
|
@@ -1332,16 +1355,14 @@ def list_cloud_workspaces( | |
| organization_id: Annotated[ | ||
| str | None, | ||
| Field( | ||
| description="Organization ID. Required if organization_name is not provided.", | ||
| description="Optional organization ID to list workspaces within.", | ||
| default=None, | ||
| ), | ||
| ], | ||
| organization_name: Annotated[ | ||
| str | None, | ||
| Field( | ||
| description=( | ||
| "Organization name (exact match). " "Required if organization_id is not provided." | ||
| ), | ||
| description=("Optional organization name (exact match) to list workspaces within."), | ||
| default=None, | ||
| ), | ||
| ], | ||
|
|
@@ -1359,35 +1380,105 @@ def list_cloud_workspaces( | |
| default=None, | ||
| ), | ||
| ], | ||
| ) -> list[CloudWorkspaceResult]: | ||
| """List all workspaces in a specific organization. | ||
| ) -> CloudWorkspaceListResult: | ||
| """List all workspaces visible to the authenticated credentials. | ||
|
|
||
| Requires either organization_id OR organization_name (exact match) to be provided. | ||
| This tool will NOT list workspaces across all organizations - you must specify | ||
| which organization to list workspaces from. | ||
| When an organization ID or exact organization name is provided, the Config API | ||
| lists workspaces in that organization. When neither is provided, the public API | ||
| lists workspaces across organizations visible to the current credentials. | ||
| """ | ||
| client = _get_cloud_client(ctx) | ||
|
|
||
| resolved_org_id = _resolve_organization_id( | ||
| organization_id=organization_id, | ||
| organization_name=organization_name, | ||
| client=client, | ||
| ) | ||
|
|
||
| workspaces = client.list_workspaces( | ||
| organization_id=resolved_org_id, | ||
| name_contains=name_contains, | ||
| limit=limit, | ||
| ) | ||
| try: | ||
| workspaces = client.list_workspaces( | ||
| organization_id=( | ||
| _resolve_organization_id( | ||
| organization_id=organization_id, | ||
| organization_name=organization_name, | ||
| client=client, | ||
| ) | ||
| if organization_id is not None or organization_name is not None | ||
| else None | ||
| ), | ||
| name_contains=name_contains, | ||
| limit=limit, | ||
| ) | ||
| except AirbyteError as error: | ||
| status_code = (error.context or {}).get("status_code") | ||
| if status_code not in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: | ||
| raise | ||
| return CloudWorkspaceListResult( | ||
| workspaces=[], | ||
| message=( | ||
| "Workspace discovery is unavailable because these credentials do not " | ||
| "have permission to list workspaces. Provide a workspace ID or use " | ||
| "credentials with workspace access." | ||
| ), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, fixing. That |
||
| ) | ||
|
|
||
| return [ | ||
| results = [ | ||
| CloudWorkspaceResult( | ||
| workspace_id=ws.workspace_id, | ||
| workspace_name=ws.name, | ||
| organization_id=ws.organization_id or "", | ||
| ) | ||
|
Comment on lines
1437
to
1441
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, fixing. Making |
||
| for ws in workspaces | ||
| ] | ||
| return CloudWorkspaceListResult( | ||
| workspaces=results, | ||
| message=( | ||
| "No workspaces were returned for these credentials. Verify the " | ||
| "credentials or ask the user to provide a workspace ID." | ||
| if not results | ||
| else None | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| @mcp_tool( | ||
| read_only=True, | ||
| idempotent=True, | ||
| open_world=True, | ||
| extra_help_text=CLOUD_AUTH_TIP_TEXT, | ||
| ) | ||
| def list_cloud_organizations( | ||
| ctx: Context, | ||
| ) -> CloudOrganizationListResult: | ||
| """List organizations visible to the authenticated Airbyte Cloud credentials.""" | ||
| try: | ||
| organizations = _get_cloud_client(ctx).list_organizations() | ||
| except AirbyteError as error: | ||
| status_code = (error.context or {}).get("status_code") | ||
| if status_code not in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: | ||
| raise | ||
| return CloudOrganizationListResult( | ||
| organizations=[], | ||
| message=( | ||
| "Organization discovery is unavailable because these credentials do not " | ||
| "have permission to list organizations. Provide an organization ID or " | ||
| "use credentials with organization reader access." | ||
| ), | ||
| ) | ||
|
|
||
| if not organizations: | ||
| return CloudOrganizationListResult( | ||
| organizations=[], | ||
| message=( | ||
| "No organizations were returned for these credentials. Verify the " | ||
| "credentials or ask the user to provide an organization ID." | ||
| ), | ||
| ) | ||
|
|
||
| return CloudOrganizationListResult( | ||
| organizations=[ | ||
| CloudOrganizationResult( | ||
| id=organization.organization_id, | ||
| name=organization.organization_name or "", | ||
| email=organization.email or "", | ||
| ) | ||
| for organization in organizations | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| @mcp_tool( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.