airbyte-internal-ops 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/METADATA +19 -3
- {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/RECORD +41 -41
- airbyte_ops_mcp/__init__.py +2 -2
- airbyte_ops_mcp/cli/cloud.py +207 -306
- airbyte_ops_mcp/cloud_admin/api_client.py +51 -26
- airbyte_ops_mcp/cloud_admin/connection_config.py +2 -2
- airbyte_ops_mcp/constants.py +61 -1
- airbyte_ops_mcp/github_actions.py +69 -1
- airbyte_ops_mcp/mcp/_http_headers.py +56 -0
- airbyte_ops_mcp/mcp/_mcp_utils.py +2 -2
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +57 -43
- airbyte_ops_mcp/mcp/github.py +34 -1
- airbyte_ops_mcp/mcp/prerelease.py +3 -3
- airbyte_ops_mcp/mcp/prod_db_queries.py +293 -50
- airbyte_ops_mcp/mcp/{live_tests.py → regression_tests.py} +158 -176
- airbyte_ops_mcp/mcp/server.py +3 -3
- airbyte_ops_mcp/prod_db_access/db_engine.py +7 -11
- airbyte_ops_mcp/prod_db_access/queries.py +79 -0
- airbyte_ops_mcp/prod_db_access/sql.py +86 -0
- airbyte_ops_mcp/{live_tests → regression_tests}/__init__.py +3 -3
- airbyte_ops_mcp/{live_tests → regression_tests}/cdk_secrets.py +1 -1
- airbyte_ops_mcp/{live_tests → regression_tests}/connection_secret_retriever.py +3 -3
- airbyte_ops_mcp/{live_tests → regression_tests}/connector_runner.py +1 -1
- airbyte_ops_mcp/{live_tests → regression_tests}/message_cache/__init__.py +3 -1
- airbyte_ops_mcp/{live_tests → regression_tests}/regression/__init__.py +1 -1
- airbyte_ops_mcp/{live_tests → regression_tests}/schema_generation.py +3 -1
- airbyte_ops_mcp/{live_tests → regression_tests}/validation/__init__.py +2 -2
- airbyte_ops_mcp/{live_tests → regression_tests}/validation/record_validators.py +4 -2
- {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.2.0.dist-info → airbyte_internal_ops-0.2.2.dist-info}/entry_points.txt +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/ci_output.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/commons/__init__.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/config.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/connection_fetcher.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/evaluation_modes.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/http_metrics.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/message_cache/duckdb_cache.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/models.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/obfuscation.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/regression/comparators.py +0 -0
- /airbyte_ops_mcp/{live_tests → regression_tests}/validation/catalog_validators.py +0 -0
|
@@ -48,21 +48,36 @@ class _OriginType(str, Enum):
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _get_access_token(
|
|
51
|
-
client_id: str,
|
|
52
|
-
client_secret: str,
|
|
51
|
+
client_id: str | None = None,
|
|
52
|
+
client_secret: str | None = None,
|
|
53
|
+
bearer_token: str | None = None,
|
|
53
54
|
) -> str:
|
|
54
55
|
"""Get an access token for Airbyte Cloud API.
|
|
55
56
|
|
|
57
|
+
If a bearer_token is provided, it is returned directly (already an access token).
|
|
58
|
+
Otherwise, exchanges client_id/client_secret for an access token.
|
|
59
|
+
|
|
56
60
|
Args:
|
|
57
|
-
client_id: The Airbyte Cloud client ID
|
|
58
|
-
client_secret: The Airbyte Cloud client secret
|
|
61
|
+
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
62
|
+
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
63
|
+
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
59
64
|
|
|
60
65
|
Returns:
|
|
61
66
|
Access token string
|
|
62
67
|
|
|
63
68
|
Raises:
|
|
64
|
-
PyAirbyteInputError: If authentication fails
|
|
69
|
+
PyAirbyteInputError: If authentication fails or no valid credentials provided
|
|
65
70
|
"""
|
|
71
|
+
# If bearer token provided, use it directly
|
|
72
|
+
if bearer_token:
|
|
73
|
+
return bearer_token
|
|
74
|
+
|
|
75
|
+
# Otherwise, exchange client credentials for access token
|
|
76
|
+
if not client_id or not client_secret:
|
|
77
|
+
raise PyAirbyteInputError(
|
|
78
|
+
message="Either bearer_token or both client_id and client_secret must be provided",
|
|
79
|
+
)
|
|
80
|
+
|
|
66
81
|
# Always authenticate via the public API endpoint
|
|
67
82
|
auth_url = f"{constants.CLOUD_API_ROOT}/applications/token"
|
|
68
83
|
response = requests.post(
|
|
@@ -90,16 +105,18 @@ def _get_access_token(
|
|
|
90
105
|
def get_user_id_by_email(
|
|
91
106
|
email: str,
|
|
92
107
|
api_root: str,
|
|
93
|
-
client_id: str,
|
|
94
|
-
client_secret: str,
|
|
108
|
+
client_id: str | None = None,
|
|
109
|
+
client_secret: str | None = None,
|
|
110
|
+
bearer_token: str | None = None,
|
|
95
111
|
) -> str:
|
|
96
112
|
"""Get user ID from email address.
|
|
97
113
|
|
|
98
114
|
Args:
|
|
99
115
|
email: The user's email address
|
|
100
116
|
api_root: The API root URL
|
|
101
|
-
client_id: The Airbyte Cloud client ID
|
|
102
|
-
client_secret: The Airbyte Cloud client secret
|
|
117
|
+
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
118
|
+
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
119
|
+
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
103
120
|
|
|
104
121
|
Returns:
|
|
105
122
|
User ID (UUID string)
|
|
@@ -107,7 +124,7 @@ def get_user_id_by_email(
|
|
|
107
124
|
Raises:
|
|
108
125
|
PyAirbyteInputError: If user not found or API request fails
|
|
109
126
|
"""
|
|
110
|
-
access_token = _get_access_token(client_id, client_secret)
|
|
127
|
+
access_token = _get_access_token(client_id, client_secret, bearer_token)
|
|
111
128
|
|
|
112
129
|
endpoint = f"{api_root}/users/list_instance_admin"
|
|
113
130
|
response = requests.post(
|
|
@@ -152,8 +169,9 @@ def resolve_connector_version_id(
|
|
|
152
169
|
connector_type: Literal["source", "destination"],
|
|
153
170
|
version: str,
|
|
154
171
|
api_root: str,
|
|
155
|
-
client_id: str,
|
|
156
|
-
client_secret: str,
|
|
172
|
+
client_id: str | None = None,
|
|
173
|
+
client_secret: str | None = None,
|
|
174
|
+
bearer_token: str | None = None,
|
|
157
175
|
) -> str:
|
|
158
176
|
"""Resolve a version string to an actor_definition_version_id.
|
|
159
177
|
|
|
@@ -162,8 +180,9 @@ def resolve_connector_version_id(
|
|
|
162
180
|
connector_type: Either "source" or "destination"
|
|
163
181
|
version: The version string (e.g., "0.1.47-preview.abe7cb4")
|
|
164
182
|
api_root: The API root URL
|
|
165
|
-
client_id: The Airbyte Cloud client ID
|
|
166
|
-
client_secret: The Airbyte Cloud client secret
|
|
183
|
+
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
184
|
+
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
185
|
+
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
167
186
|
|
|
168
187
|
Returns:
|
|
169
188
|
Version ID (UUID string)
|
|
@@ -171,7 +190,7 @@ def resolve_connector_version_id(
|
|
|
171
190
|
Raises:
|
|
172
191
|
PyAirbyteInputError: If version cannot be resolved or API request fails
|
|
173
192
|
"""
|
|
174
|
-
access_token = _get_access_token(client_id, client_secret)
|
|
193
|
+
access_token = _get_access_token(client_id, client_secret, bearer_token)
|
|
175
194
|
|
|
176
195
|
endpoint = f"{api_root}/actor_definition_versions/resolve"
|
|
177
196
|
payload = {
|
|
@@ -223,8 +242,9 @@ def get_connector_version(
|
|
|
223
242
|
connector_id: str,
|
|
224
243
|
connector_type: Literal["source", "destination"],
|
|
225
244
|
api_root: str,
|
|
226
|
-
client_id: str,
|
|
227
|
-
client_secret: str,
|
|
245
|
+
client_id: str | None = None,
|
|
246
|
+
client_secret: str | None = None,
|
|
247
|
+
bearer_token: str | None = None,
|
|
228
248
|
) -> dict[str, Any]:
|
|
229
249
|
"""Get version information for a deployed connector.
|
|
230
250
|
|
|
@@ -232,8 +252,9 @@ def get_connector_version(
|
|
|
232
252
|
connector_id: The ID of the deployed connector (source or destination)
|
|
233
253
|
connector_type: Either "source" or "destination"
|
|
234
254
|
api_root: The API root URL
|
|
235
|
-
client_id: The Airbyte Cloud client ID
|
|
236
|
-
client_secret: The Airbyte Cloud client secret
|
|
255
|
+
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
256
|
+
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
257
|
+
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
237
258
|
|
|
238
259
|
Returns:
|
|
239
260
|
Dictionary containing:
|
|
@@ -243,7 +264,7 @@ def get_connector_version(
|
|
|
243
264
|
Raises:
|
|
244
265
|
PyAirbyteInputError: If the API request fails
|
|
245
266
|
"""
|
|
246
|
-
access_token = _get_access_token(client_id, client_secret)
|
|
267
|
+
access_token = _get_access_token(client_id, client_secret, bearer_token)
|
|
247
268
|
|
|
248
269
|
# Determine endpoint based on connector type
|
|
249
270
|
# api_root already includes /v1
|
|
@@ -294,14 +315,15 @@ def set_connector_version_override(
|
|
|
294
315
|
connector_id: str,
|
|
295
316
|
connector_type: Literal["source", "destination"],
|
|
296
317
|
api_root: str,
|
|
297
|
-
client_id: str,
|
|
298
|
-
client_secret: str,
|
|
299
|
-
workspace_id: str,
|
|
318
|
+
client_id: str | None = None,
|
|
319
|
+
client_secret: str | None = None,
|
|
320
|
+
workspace_id: str | None = None,
|
|
300
321
|
version: str | None = None,
|
|
301
322
|
unset: bool = False,
|
|
302
323
|
override_reason: str | None = None,
|
|
303
324
|
override_reason_reference_url: str | None = None,
|
|
304
325
|
user_email: str | None = None,
|
|
326
|
+
bearer_token: str | None = None,
|
|
305
327
|
) -> bool:
|
|
306
328
|
"""Set or clear a version override for a deployed connector.
|
|
307
329
|
|
|
@@ -309,14 +331,15 @@ def set_connector_version_override(
|
|
|
309
331
|
connector_id: The ID of the deployed connector
|
|
310
332
|
connector_type: Either "source" or "destination"
|
|
311
333
|
api_root: The API root URL
|
|
312
|
-
client_id: The Airbyte Cloud client ID
|
|
313
|
-
client_secret: The Airbyte Cloud client secret
|
|
334
|
+
client_id: The Airbyte Cloud client ID (required if no bearer_token)
|
|
335
|
+
client_secret: The Airbyte Cloud client secret (required if no bearer_token)
|
|
314
336
|
workspace_id: The workspace ID
|
|
315
337
|
version: The version to pin to (e.g., "0.1.0"), or None to unset
|
|
316
338
|
unset: If True, removes any existing override
|
|
317
339
|
override_reason: Required when setting. Explanation for the override
|
|
318
340
|
override_reason_reference_url: Optional URL with more context
|
|
319
341
|
user_email: Email of user creating the override
|
|
342
|
+
bearer_token: Pre-existing bearer token (takes precedence over client credentials)
|
|
320
343
|
|
|
321
344
|
Returns:
|
|
322
345
|
True if operation succeeded, False if no override existed (unset only)
|
|
@@ -335,7 +358,7 @@ def set_connector_version_override(
|
|
|
335
358
|
message="override_reason is required when setting a version and must be at least 10 characters",
|
|
336
359
|
)
|
|
337
360
|
|
|
338
|
-
access_token = _get_access_token(client_id, client_secret)
|
|
361
|
+
access_token = _get_access_token(client_id, client_secret, bearer_token)
|
|
339
362
|
|
|
340
363
|
# Build the scoped configuration
|
|
341
364
|
scope_type = _ScopeType.ACTOR
|
|
@@ -479,6 +502,7 @@ def set_connector_version_override(
|
|
|
479
502
|
api_root=api_root,
|
|
480
503
|
client_id=client_id,
|
|
481
504
|
client_secret=client_secret,
|
|
505
|
+
bearer_token=bearer_token,
|
|
482
506
|
)
|
|
483
507
|
|
|
484
508
|
# Get user ID from email if provided
|
|
@@ -489,6 +513,7 @@ def set_connector_version_override(
|
|
|
489
513
|
api_root=api_root,
|
|
490
514
|
client_id=client_id,
|
|
491
515
|
client_secret=client_secret,
|
|
516
|
+
bearer_token=bearer_token,
|
|
492
517
|
)
|
|
493
518
|
|
|
494
519
|
# Create the override with correct schema
|
|
@@ -13,8 +13,8 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
from pydantic import BaseModel, Field
|
|
15
15
|
|
|
16
|
-
from airbyte_ops_mcp.
|
|
17
|
-
from airbyte_ops_mcp.
|
|
16
|
+
from airbyte_ops_mcp.regression_tests.connection_fetcher import fetch_connection_data
|
|
17
|
+
from airbyte_ops_mcp.regression_tests.connection_secret_retriever import (
|
|
18
18
|
retrieve_unmasked_config,
|
|
19
19
|
)
|
|
20
20
|
|
airbyte_ops_mcp/constants.py
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
-
from enum import Enum
|
|
6
|
+
from enum import Enum, StrEnum
|
|
7
|
+
|
|
8
|
+
from airbyte.exceptions import PyAirbyteInputError
|
|
7
9
|
|
|
8
10
|
MCP_SERVER_NAME = "airbyte-internal-ops"
|
|
9
11
|
"""The name of the MCP server."""
|
|
@@ -59,6 +61,64 @@ CLOUD_REGISTRY_URL = (
|
|
|
59
61
|
)
|
|
60
62
|
"""URL for the Airbyte Cloud connector registry."""
|
|
61
63
|
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# Organization ID Aliases
|
|
66
|
+
# =============================================================================
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OrganizationAliasEnum(StrEnum):
|
|
70
|
+
"""Organization ID aliases that can be used in place of UUIDs.
|
|
71
|
+
|
|
72
|
+
Each member's name is the alias (e.g., "@airbyte-internal") and its value
|
|
73
|
+
is the actual organization UUID. Use `OrganizationAliasEnum.resolve()` to
|
|
74
|
+
resolve aliases to actual IDs.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
AIRBYTE_INTERNAL = "664c690e-5263-49ba-b01f-4a6759b3330a"
|
|
78
|
+
"""The Airbyte internal organization for testing and internal operations.
|
|
79
|
+
|
|
80
|
+
Alias: @airbyte-internal
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def resolve(cls, org_id: str | None) -> str | None:
|
|
85
|
+
"""Resolve an organization ID alias to its actual UUID.
|
|
86
|
+
|
|
87
|
+
Accepts either an alias string (e.g., "@airbyte-internal") or an
|
|
88
|
+
OrganizationAliasEnum enum member, and returns the actual UUID.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The resolved organization ID (UUID), or None if input is None.
|
|
92
|
+
If the input doesn't start with "@", it is returned unchanged.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
PyAirbyteInputError: If the input starts with "@" but is not a recognized alias.
|
|
96
|
+
"""
|
|
97
|
+
if org_id is None:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Handle OrganizationAliasEnum enum members directly
|
|
101
|
+
if isinstance(org_id, cls):
|
|
102
|
+
return org_id.value
|
|
103
|
+
|
|
104
|
+
# If it doesn't look like an alias, return as-is (assume it's a UUID)
|
|
105
|
+
if not org_id.startswith("@"):
|
|
106
|
+
return org_id
|
|
107
|
+
|
|
108
|
+
# Handle alias strings or raise an error if invalid
|
|
109
|
+
alias_mapping = {
|
|
110
|
+
"@airbyte-internal": cls.AIRBYTE_INTERNAL.value,
|
|
111
|
+
}
|
|
112
|
+
if org_id not in alias_mapping:
|
|
113
|
+
raise PyAirbyteInputError(
|
|
114
|
+
message=f"Unknown organization alias: {org_id}",
|
|
115
|
+
context={
|
|
116
|
+
"valid_aliases": list(alias_mapping.keys()),
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
return alias_mapping[org_id]
|
|
120
|
+
|
|
121
|
+
|
|
62
122
|
CONNECTION_RETRIEVER_PG_CONNECTION_DETAILS_SECRET_ID = (
|
|
63
123
|
"projects/587336813068/secrets/CONNECTION_RETRIEVER_PG_CONNECTION_DETAILS"
|
|
64
124
|
)
|
|
@@ -83,6 +83,74 @@ class WorkflowDispatchResult:
|
|
|
83
83
|
"""Direct URL to the workflow run, if discovered"""
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
@dataclass
|
|
87
|
+
class WorkflowJobInfo:
|
|
88
|
+
"""Information about a single job in a workflow run."""
|
|
89
|
+
|
|
90
|
+
job_id: int
|
|
91
|
+
"""GitHub job ID (use with git_ci_job_logs to retrieve logs)"""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
"""Job name as defined in the workflow"""
|
|
95
|
+
|
|
96
|
+
status: str
|
|
97
|
+
"""Job status: queued, in_progress, completed"""
|
|
98
|
+
|
|
99
|
+
conclusion: str | None = None
|
|
100
|
+
"""Job conclusion: success, failure, cancelled, skipped (only set when completed)"""
|
|
101
|
+
|
|
102
|
+
started_at: str | None = None
|
|
103
|
+
"""ISO 8601 timestamp when the job started"""
|
|
104
|
+
|
|
105
|
+
completed_at: str | None = None
|
|
106
|
+
"""ISO 8601 timestamp when the job completed"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_workflow_jobs(
|
|
110
|
+
owner: str,
|
|
111
|
+
repo: str,
|
|
112
|
+
run_id: int,
|
|
113
|
+
) -> list[WorkflowJobInfo]:
|
|
114
|
+
"""Get jobs for a workflow run from GitHub API.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
owner: Repository owner (e.g., "airbytehq")
|
|
118
|
+
repo: Repository name (e.g., "airbyte")
|
|
119
|
+
run_id: Workflow run ID
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of WorkflowJobInfo objects for each job in the workflow run.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If no GitHub token is found.
|
|
126
|
+
requests.HTTPError: If API request fails.
|
|
127
|
+
"""
|
|
128
|
+
token = resolve_github_token()
|
|
129
|
+
|
|
130
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
|
131
|
+
headers = {
|
|
132
|
+
"Authorization": f"Bearer {token}",
|
|
133
|
+
"Accept": "application/vnd.github+json",
|
|
134
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
response = requests.get(url, headers=headers, timeout=30)
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
|
|
140
|
+
jobs_data = response.json()
|
|
141
|
+
return [
|
|
142
|
+
WorkflowJobInfo(
|
|
143
|
+
job_id=job["id"],
|
|
144
|
+
name=job["name"],
|
|
145
|
+
status=job["status"],
|
|
146
|
+
conclusion=job.get("conclusion"),
|
|
147
|
+
started_at=job.get("started_at"),
|
|
148
|
+
completed_at=job.get("completed_at"),
|
|
149
|
+
)
|
|
150
|
+
for job in jobs_data.get("jobs", [])
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
86
154
|
def find_workflow_run(
|
|
87
155
|
owner: str,
|
|
88
156
|
repo: str,
|
|
@@ -167,7 +235,7 @@ def trigger_workflow_dispatch(
|
|
|
167
235
|
Args:
|
|
168
236
|
owner: Repository owner (e.g., "airbytehq")
|
|
169
237
|
repo: Repository name (e.g., "airbyte-ops-mcp")
|
|
170
|
-
workflow_file: Workflow file name (e.g., "connector-
|
|
238
|
+
workflow_file: Workflow file name (e.g., "connector-regression-test.yml")
|
|
171
239
|
ref: Git ref to run the workflow on (branch name)
|
|
172
240
|
inputs: Workflow inputs dictionary
|
|
173
241
|
token: GitHub API token
|
|
@@ -15,6 +15,8 @@ for the MCP module and should not be imported directly by external code.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import os
|
|
19
|
+
|
|
18
20
|
from airbyte.cloud.auth import (
|
|
19
21
|
resolve_cloud_api_url,
|
|
20
22
|
resolve_cloud_client_id,
|
|
@@ -49,6 +51,33 @@ def _get_header_value(headers: dict[str, str], header_name: str) -> str | None:
|
|
|
49
51
|
return None
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def get_bearer_token_from_headers() -> SecretString | None:
|
|
55
|
+
"""Extract bearer token from HTTP Authorization header.
|
|
56
|
+
|
|
57
|
+
This function extracts the bearer token from the standard HTTP
|
|
58
|
+
`Authorization: Bearer <token>` header when running as an MCP HTTP server.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
The bearer token as a SecretString, or None if not found or not in HTTP context.
|
|
62
|
+
"""
|
|
63
|
+
headers = get_http_headers()
|
|
64
|
+
if not headers:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
auth_header = _get_header_value(headers, "Authorization")
|
|
68
|
+
if not auth_header:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# Parse "Bearer <token>" format (case-insensitive prefix check)
|
|
72
|
+
bearer_prefix = "bearer "
|
|
73
|
+
if auth_header.lower().startswith(bearer_prefix):
|
|
74
|
+
token = auth_header[len(bearer_prefix) :].strip()
|
|
75
|
+
if token:
|
|
76
|
+
return SecretString(token)
|
|
77
|
+
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
52
81
|
def get_client_id_from_headers() -> SecretString | None:
|
|
53
82
|
"""Extract client ID from HTTP headers.
|
|
54
83
|
|
|
@@ -196,3 +225,30 @@ def resolve_api_url(api_url: str | None = None) -> str:
|
|
|
196
225
|
return header_value
|
|
197
226
|
|
|
198
227
|
return resolve_cloud_api_url()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def resolve_bearer_token() -> SecretString | None:
|
|
231
|
+
"""Resolve bearer token from HTTP headers or environment variables.
|
|
232
|
+
|
|
233
|
+
Resolution order:
|
|
234
|
+
1. HTTP Authorization header (Bearer <token>)
|
|
235
|
+
2. Environment variable AIRBYTE_CLOUD_BEARER_TOKEN
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The resolved bearer token as a SecretString, or None if not found.
|
|
239
|
+
|
|
240
|
+
Note:
|
|
241
|
+
Unlike resolve_client_id/resolve_client_secret, this function returns
|
|
242
|
+
None instead of raising an exception if no bearer token is found,
|
|
243
|
+
since bearer token auth is optional (can fall back to client credentials).
|
|
244
|
+
"""
|
|
245
|
+
header_value = get_bearer_token_from_headers()
|
|
246
|
+
if header_value:
|
|
247
|
+
return header_value
|
|
248
|
+
|
|
249
|
+
# Try env var directly
|
|
250
|
+
env_value = os.environ.get("AIRBYTE_CLOUD_BEARER_TOKEN")
|
|
251
|
+
if env_value:
|
|
252
|
+
return SecretString(env_value)
|
|
253
|
+
|
|
254
|
+
return None
|
|
@@ -76,8 +76,8 @@ class ToolDomain(str, Enum):
|
|
|
76
76
|
PROMPTS = "prompts"
|
|
77
77
|
"""Prompt templates for common workflows"""
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
"""
|
|
79
|
+
REGRESSION_TESTS = "regression_tests"
|
|
80
|
+
"""Regression tests for connector validation and comparison testing"""
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
_REGISTERED_TOOLS: list[tuple[Callable[..., Any], dict[str, Any]]] = []
|
|
@@ -5,16 +5,15 @@ This module provides MCP tools for viewing and managing connector version
|
|
|
5
5
|
overrides (pins) in Airbyte Cloud. These tools enable admins to pin connectors
|
|
6
6
|
to specific versions for troubleshooting or stability purposes.
|
|
7
7
|
|
|
8
|
-
Uses
|
|
9
|
-
for all cloud operations.
|
|
8
|
+
Uses direct API client calls with either bearer token or client credentials auth.
|
|
10
9
|
"""
|
|
11
10
|
|
|
12
11
|
from __future__ import annotations
|
|
13
12
|
|
|
13
|
+
from dataclasses import dataclass
|
|
14
14
|
from typing import Annotated, Literal
|
|
15
15
|
|
|
16
16
|
from airbyte import constants
|
|
17
|
-
from airbyte.cloud import CloudWorkspace
|
|
18
17
|
from airbyte.exceptions import PyAirbyteInputError
|
|
19
18
|
from fastmcp import FastMCP
|
|
20
19
|
from pydantic import Field
|
|
@@ -29,41 +28,57 @@ from airbyte_ops_mcp.cloud_admin.models import (
|
|
|
29
28
|
VersionOverrideOperationResult,
|
|
30
29
|
)
|
|
31
30
|
from airbyte_ops_mcp.mcp._http_headers import (
|
|
31
|
+
resolve_bearer_token,
|
|
32
32
|
resolve_client_id,
|
|
33
33
|
resolve_client_secret,
|
|
34
34
|
)
|
|
35
35
|
from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class _ResolvedCloudAuth:
|
|
40
|
+
"""Resolved authentication for Airbyte Cloud API calls.
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
Either bearer_token OR (client_id AND client_secret) will be set, not both.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
bearer_token: str | None = None
|
|
46
|
+
client_id: str | None = None
|
|
47
|
+
client_secret: str | None = None
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
|
|
50
|
+
def _resolve_cloud_auth() -> _ResolvedCloudAuth:
|
|
51
|
+
"""Resolve authentication credentials for Airbyte Cloud API.
|
|
52
|
+
|
|
53
|
+
Credentials are resolved in priority order:
|
|
54
|
+
1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
|
|
55
|
+
2. Client credentials (X-Airbyte-Cloud-Client-Id/Secret headers or env vars)
|
|
47
56
|
|
|
48
57
|
Returns:
|
|
49
|
-
|
|
58
|
+
_ResolvedCloudAuth with either bearer_token or client credentials set.
|
|
50
59
|
|
|
51
60
|
Raises:
|
|
52
61
|
CloudAuthError: If credentials cannot be resolved from headers or env vars.
|
|
53
62
|
"""
|
|
63
|
+
# Try bearer token first (preferred)
|
|
64
|
+
bearer_token = resolve_bearer_token()
|
|
65
|
+
if bearer_token:
|
|
66
|
+
return _ResolvedCloudAuth(bearer_token=str(bearer_token))
|
|
67
|
+
|
|
68
|
+
# Fall back to client credentials
|
|
54
69
|
try:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
client_id = resolve_client_id()
|
|
71
|
+
client_secret = resolve_client_secret()
|
|
72
|
+
return _ResolvedCloudAuth(
|
|
73
|
+
client_id=str(client_id),
|
|
74
|
+
client_secret=str(client_secret),
|
|
60
75
|
)
|
|
61
76
|
except Exception as e:
|
|
62
77
|
raise CloudAuthError(
|
|
63
|
-
f"Failed to
|
|
64
|
-
f"via
|
|
65
|
-
f"
|
|
66
|
-
f"Error: {e}"
|
|
78
|
+
f"Failed to resolve credentials. Ensure credentials are provided "
|
|
79
|
+
f"via Authorization header (Bearer token), "
|
|
80
|
+
f"HTTP headers (X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret), "
|
|
81
|
+
f"or environment variables. Error: {e}"
|
|
67
82
|
) from e
|
|
68
83
|
|
|
69
84
|
|
|
@@ -91,11 +106,12 @@ def get_cloud_connector_version(
|
|
|
91
106
|
an override (pin) is applied.
|
|
92
107
|
|
|
93
108
|
Authentication credentials are resolved in priority order:
|
|
94
|
-
1.
|
|
95
|
-
2.
|
|
109
|
+
1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
|
|
110
|
+
2. HTTP headers: X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret
|
|
111
|
+
3. Environment variables: AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET
|
|
96
112
|
"""
|
|
97
113
|
try:
|
|
98
|
-
|
|
114
|
+
auth = _resolve_cloud_auth()
|
|
99
115
|
|
|
100
116
|
# Use vendored API client instead of connector.get_connector_version()
|
|
101
117
|
# Use Config API root for version management operations
|
|
@@ -103,8 +119,9 @@ def get_cloud_connector_version(
|
|
|
103
119
|
connector_id=actor_id,
|
|
104
120
|
connector_type=actor_type,
|
|
105
121
|
api_root=constants.CLOUD_CONFIG_API_ROOT, # Use Config API, not public API
|
|
106
|
-
client_id=
|
|
107
|
-
client_secret=
|
|
122
|
+
client_id=auth.client_id,
|
|
123
|
+
client_secret=auth.client_secret,
|
|
124
|
+
bearer_token=auth.bearer_token,
|
|
108
125
|
)
|
|
109
126
|
|
|
110
127
|
return ConnectorVersionInfo(
|
|
@@ -220,8 +237,9 @@ def set_cloud_connector_version_override(
|
|
|
220
237
|
- Release candidates (-rc): Any admin can pin/unpin RC versions
|
|
221
238
|
|
|
222
239
|
Authentication credentials are resolved in priority order:
|
|
223
|
-
1.
|
|
224
|
-
2.
|
|
240
|
+
1. Bearer token (Authorization header or AIRBYTE_CLOUD_BEARER_TOKEN env var)
|
|
241
|
+
2. HTTP headers: X-Airbyte-Cloud-Client-Id, X-Airbyte-Cloud-Client-Secret
|
|
242
|
+
3. Environment variables: AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET
|
|
225
243
|
"""
|
|
226
244
|
# Validate admin access (check env var flag)
|
|
227
245
|
try:
|
|
@@ -288,17 +306,18 @@ def set_cloud_connector_version_override(
|
|
|
288
306
|
audit_parts.append(f"AI Session: {ai_agent_session_url}")
|
|
289
307
|
enhanced_override_reason = " | ".join(audit_parts)
|
|
290
308
|
|
|
291
|
-
#
|
|
309
|
+
# Resolve auth and get current version info
|
|
292
310
|
try:
|
|
293
|
-
|
|
311
|
+
auth = _resolve_cloud_auth()
|
|
294
312
|
|
|
295
313
|
# Get current version info before the operation
|
|
296
314
|
current_version_data = api_client.get_connector_version(
|
|
297
315
|
connector_id=actor_id,
|
|
298
316
|
connector_type=actor_type,
|
|
299
317
|
api_root=constants.CLOUD_CONFIG_API_ROOT, # Use Config API
|
|
300
|
-
client_id=
|
|
301
|
-
client_secret=
|
|
318
|
+
client_id=auth.client_id,
|
|
319
|
+
client_secret=auth.client_secret,
|
|
320
|
+
bearer_token=auth.bearer_token,
|
|
302
321
|
)
|
|
303
322
|
current_version = current_version_data["dockerImageTag"]
|
|
304
323
|
was_pinned_before = current_version_data["isVersionOverrideApplied"]
|
|
@@ -306,14 +325,7 @@ def set_cloud_connector_version_override(
|
|
|
306
325
|
except CloudAuthError as e:
|
|
307
326
|
return VersionOverrideOperationResult(
|
|
308
327
|
success=False,
|
|
309
|
-
message=f"Failed to
|
|
310
|
-
connector_id=actor_id,
|
|
311
|
-
connector_type=actor_type,
|
|
312
|
-
)
|
|
313
|
-
except Exception as e:
|
|
314
|
-
return VersionOverrideOperationResult(
|
|
315
|
-
success=False,
|
|
316
|
-
message=f"Failed to get connector: {e}",
|
|
328
|
+
message=f"Failed to resolve credentials or get connector: {e}",
|
|
317
329
|
connector_id=actor_id,
|
|
318
330
|
connector_type=actor_type,
|
|
319
331
|
)
|
|
@@ -324,14 +336,15 @@ def set_cloud_connector_version_override(
|
|
|
324
336
|
connector_id=actor_id,
|
|
325
337
|
connector_type=actor_type,
|
|
326
338
|
api_root=constants.CLOUD_CONFIG_API_ROOT, # Use Config API
|
|
327
|
-
client_id=
|
|
328
|
-
client_secret=
|
|
339
|
+
client_id=auth.client_id,
|
|
340
|
+
client_secret=auth.client_secret,
|
|
329
341
|
workspace_id=workspace_id,
|
|
330
342
|
version=version,
|
|
331
343
|
unset=unset,
|
|
332
344
|
override_reason=enhanced_override_reason,
|
|
333
345
|
override_reason_reference_url=override_reason_reference_url,
|
|
334
346
|
user_email=admin_user_email,
|
|
347
|
+
bearer_token=auth.bearer_token,
|
|
335
348
|
)
|
|
336
349
|
|
|
337
350
|
# Get updated version info after the operation
|
|
@@ -339,8 +352,9 @@ def set_cloud_connector_version_override(
|
|
|
339
352
|
connector_id=actor_id,
|
|
340
353
|
connector_type=actor_type,
|
|
341
354
|
api_root=constants.CLOUD_CONFIG_API_ROOT, # Use Config API
|
|
342
|
-
client_id=
|
|
343
|
-
client_secret=
|
|
355
|
+
client_id=auth.client_id,
|
|
356
|
+
client_secret=auth.client_secret,
|
|
357
|
+
bearer_token=auth.bearer_token,
|
|
344
358
|
)
|
|
345
359
|
new_version = updated_version_data["dockerImageTag"] if not unset else None
|
|
346
360
|
is_pinned_after = updated_version_data["isVersionOverrideApplied"]
|