airbyte-internal-ops 0.1.11__py3-none-any.whl → 0.2.1__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.1.11.dist-info → airbyte_internal_ops-0.2.1.dist-info}/METADATA +2 -2
- {airbyte_internal_ops-0.1.11.dist-info → airbyte_internal_ops-0.2.1.dist-info}/RECORD +41 -40
- {airbyte_internal_ops-0.1.11.dist-info → airbyte_internal_ops-0.2.1.dist-info}/entry_points.txt +1 -0
- airbyte_ops_mcp/__init__.py +2 -2
- airbyte_ops_mcp/cli/cloud.py +264 -301
- airbyte_ops_mcp/cloud_admin/api_client.py +51 -26
- airbyte_ops_mcp/cloud_admin/auth.py +32 -0
- airbyte_ops_mcp/cloud_admin/connection_config.py +2 -2
- airbyte_ops_mcp/constants.py +18 -0
- airbyte_ops_mcp/github_actions.py +94 -5
- airbyte_ops_mcp/mcp/_http_headers.py +254 -0
- airbyte_ops_mcp/mcp/_mcp_utils.py +2 -2
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +162 -52
- airbyte_ops_mcp/mcp/github.py +34 -1
- airbyte_ops_mcp/mcp/prod_db_queries.py +67 -24
- airbyte_ops_mcp/mcp/{live_tests.py → regression_tests.py} +165 -152
- airbyte_ops_mcp/mcp/server.py +84 -11
- airbyte_ops_mcp/prod_db_access/db_engine.py +15 -11
- airbyte_ops_mcp/prod_db_access/queries.py +27 -15
- airbyte_ops_mcp/prod_db_access/sql.py +17 -16
- 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.1.11.dist-info → airbyte_internal_ops-0.2.1.dist-info}/WHEEL +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
|
|
@@ -41,6 +41,20 @@ def check_internal_admin_flag() -> bool:
|
|
|
41
41
|
return bool(admin_user and EXPECTED_ADMIN_EMAIL_DOMAIN in admin_user)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def check_internal_admin_flag_only() -> bool:
|
|
45
|
+
"""Check if internal admin flag is set (without requiring user email env var).
|
|
46
|
+
|
|
47
|
+
This is a lighter check that only validates AIRBYTE_INTERNAL_ADMIN_FLAG,
|
|
48
|
+
allowing the admin user email to be provided as a parameter instead of
|
|
49
|
+
an environment variable.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if AIRBYTE_INTERNAL_ADMIN_FLAG is set correctly, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
admin_flag = os.environ.get(ENV_AIRBYTE_INTERNAL_ADMIN_FLAG)
|
|
55
|
+
return admin_flag == EXPECTED_ADMIN_FLAG_VALUE
|
|
56
|
+
|
|
57
|
+
|
|
44
58
|
def require_internal_admin() -> None:
|
|
45
59
|
"""Require internal admin access for the current operation.
|
|
46
60
|
|
|
@@ -59,6 +73,24 @@ def require_internal_admin() -> None:
|
|
|
59
73
|
)
|
|
60
74
|
|
|
61
75
|
|
|
76
|
+
def require_internal_admin_flag_only() -> None:
|
|
77
|
+
"""Require internal admin flag for the current operation.
|
|
78
|
+
|
|
79
|
+
This is a lighter check that only validates AIRBYTE_INTERNAL_ADMIN_FLAG,
|
|
80
|
+
allowing the admin user email to be provided as a parameter instead of
|
|
81
|
+
an environment variable.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
CloudAuthError: If AIRBYTE_INTERNAL_ADMIN_FLAG is not properly configured
|
|
85
|
+
"""
|
|
86
|
+
if not check_internal_admin_flag_only():
|
|
87
|
+
raise CloudAuthError(
|
|
88
|
+
"This operation requires internal admin access. "
|
|
89
|
+
f"Set {ENV_AIRBYTE_INTERNAL_ADMIN_FLAG}={EXPECTED_ADMIN_FLAG_VALUE} "
|
|
90
|
+
"environment variable."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
62
94
|
def get_admin_user_email() -> str:
|
|
63
95
|
"""Get the admin user email from environment.
|
|
64
96
|
|
|
@@ -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
|
@@ -20,6 +20,24 @@ ENV_GCP_PROD_DB_ACCESS_CREDENTIALS = "GCP_PROD_DB_ACCESS_CREDENTIALS"
|
|
|
20
20
|
EXPECTED_ADMIN_FLAG_VALUE = "airbyte.io"
|
|
21
21
|
EXPECTED_ADMIN_EMAIL_DOMAIN = "@airbyte.io"
|
|
22
22
|
|
|
23
|
+
# =============================================================================
|
|
24
|
+
# HTTP Header Names for Airbyte Cloud Authentication
|
|
25
|
+
# =============================================================================
|
|
26
|
+
# These headers follow the PyAirbyte convention for passing credentials
|
|
27
|
+
# via HTTP when running as an MCP HTTP server.
|
|
28
|
+
|
|
29
|
+
HEADER_AIRBYTE_CLOUD_CLIENT_ID = "X-Airbyte-Cloud-Client-Id"
|
|
30
|
+
"""HTTP header for OAuth client ID."""
|
|
31
|
+
|
|
32
|
+
HEADER_AIRBYTE_CLOUD_CLIENT_SECRET = "X-Airbyte-Cloud-Client-Secret"
|
|
33
|
+
"""HTTP header for OAuth client secret."""
|
|
34
|
+
|
|
35
|
+
HEADER_AIRBYTE_CLOUD_WORKSPACE_ID = "X-Airbyte-Cloud-Workspace-Id"
|
|
36
|
+
"""HTTP header for default workspace ID."""
|
|
37
|
+
|
|
38
|
+
HEADER_AIRBYTE_CLOUD_API_URL = "X-Airbyte-Cloud-Api-Url"
|
|
39
|
+
"""HTTP header for API root URL override."""
|
|
40
|
+
|
|
23
41
|
# =============================================================================
|
|
24
42
|
# GCP and Prod DB Constants (from connection-retriever)
|
|
25
43
|
# =============================================================================
|
|
@@ -9,6 +9,8 @@ are used by MCP tools but are not MCP-specific.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
12
14
|
import time
|
|
13
15
|
from dataclasses import dataclass
|
|
14
16
|
from datetime import datetime, timedelta
|
|
@@ -19,10 +21,11 @@ GITHUB_API_BASE = "https://api.github.com"
|
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def resolve_github_token(preferred_env_vars: list[str] | None = None) -> str:
|
|
22
|
-
"""Resolve GitHub token from environment variables.
|
|
24
|
+
"""Resolve GitHub token from environment variables or gh CLI.
|
|
23
25
|
|
|
24
26
|
Checks environment variables in order of preference, returning the first
|
|
25
|
-
non-empty value found.
|
|
27
|
+
non-empty value found. If no environment variables are set, attempts to
|
|
28
|
+
get a token from the gh CLI tool using 'gh auth token'.
|
|
26
29
|
|
|
27
30
|
Args:
|
|
28
31
|
preferred_env_vars: List of environment variable names to check in order.
|
|
@@ -32,19 +35,37 @@ def resolve_github_token(preferred_env_vars: list[str] | None = None) -> str:
|
|
|
32
35
|
GitHub token string.
|
|
33
36
|
|
|
34
37
|
Raises:
|
|
35
|
-
ValueError: If no GitHub token is found in
|
|
38
|
+
ValueError: If no GitHub token is found in env vars or gh CLI.
|
|
36
39
|
"""
|
|
37
40
|
if preferred_env_vars is None:
|
|
38
41
|
preferred_env_vars = ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"]
|
|
39
42
|
|
|
43
|
+
# Check environment variables first
|
|
40
44
|
for env_var in preferred_env_vars:
|
|
41
45
|
token = os.getenv(env_var)
|
|
42
46
|
if token:
|
|
43
47
|
return token
|
|
44
48
|
|
|
49
|
+
# Fall back to gh CLI if available
|
|
50
|
+
gh_path = shutil.which("gh")
|
|
51
|
+
if gh_path:
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[gh_path, "auth", "token"],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
timeout=5,
|
|
58
|
+
check=False,
|
|
59
|
+
)
|
|
60
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
61
|
+
return result.stdout.strip()
|
|
62
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
45
65
|
env_var_list = ", ".join(preferred_env_vars)
|
|
46
66
|
raise ValueError(
|
|
47
|
-
f"No GitHub token found. Set one of: {env_var_list} environment variable
|
|
67
|
+
f"No GitHub token found. Set one of: {env_var_list} environment variable, "
|
|
68
|
+
"or authenticate with 'gh auth login'."
|
|
48
69
|
)
|
|
49
70
|
|
|
50
71
|
|
|
@@ -62,6 +83,74 @@ class WorkflowDispatchResult:
|
|
|
62
83
|
"""Direct URL to the workflow run, if discovered"""
|
|
63
84
|
|
|
64
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
|
+
|
|
65
154
|
def find_workflow_run(
|
|
66
155
|
owner: str,
|
|
67
156
|
repo: str,
|
|
@@ -146,7 +235,7 @@ def trigger_workflow_dispatch(
|
|
|
146
235
|
Args:
|
|
147
236
|
owner: Repository owner (e.g., "airbytehq")
|
|
148
237
|
repo: Repository name (e.g., "airbyte-ops-mcp")
|
|
149
|
-
workflow_file: Workflow file name (e.g., "connector-
|
|
238
|
+
workflow_file: Workflow file name (e.g., "connector-regression-test.yml")
|
|
150
239
|
ref: Git ref to run the workflow on (branch name)
|
|
151
240
|
inputs: Workflow inputs dictionary
|
|
152
241
|
token: GitHub API token
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""HTTP header extraction for Airbyte Cloud credentials.
|
|
3
|
+
|
|
4
|
+
This module provides internal helper functions for extracting Airbyte Cloud
|
|
5
|
+
authentication credentials from HTTP headers when running as an MCP HTTP server.
|
|
6
|
+
This enables per-request credential passing from upstream services like coral-agents.
|
|
7
|
+
|
|
8
|
+
The resolution order for credentials is:
|
|
9
|
+
1. HTTP headers (when running as MCP HTTP server)
|
|
10
|
+
2. Environment variables (fallback)
|
|
11
|
+
|
|
12
|
+
Note: This module is prefixed with "_" to indicate it is internal helper logic
|
|
13
|
+
for the MCP module and should not be imported directly by external code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from airbyte.cloud.auth import (
|
|
21
|
+
resolve_cloud_api_url,
|
|
22
|
+
resolve_cloud_client_id,
|
|
23
|
+
resolve_cloud_client_secret,
|
|
24
|
+
resolve_cloud_workspace_id,
|
|
25
|
+
)
|
|
26
|
+
from airbyte.secrets.base import SecretString
|
|
27
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
28
|
+
|
|
29
|
+
from airbyte_ops_mcp.constants import (
|
|
30
|
+
HEADER_AIRBYTE_CLOUD_API_URL,
|
|
31
|
+
HEADER_AIRBYTE_CLOUD_CLIENT_ID,
|
|
32
|
+
HEADER_AIRBYTE_CLOUD_CLIENT_SECRET,
|
|
33
|
+
HEADER_AIRBYTE_CLOUD_WORKSPACE_ID,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_header_value(headers: dict[str, str], header_name: str) -> str | None:
|
|
38
|
+
"""Get a header value from a headers dict, case-insensitively.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
headers: Dictionary of HTTP headers.
|
|
42
|
+
header_name: The header name to look for (case-insensitive).
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The header value if found, None otherwise.
|
|
46
|
+
"""
|
|
47
|
+
header_name_lower = header_name.lower()
|
|
48
|
+
for key, value in headers.items():
|
|
49
|
+
if key.lower() == header_name_lower:
|
|
50
|
+
return value
|
|
51
|
+
return None
|
|
52
|
+
|
|
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
|
+
|
|
81
|
+
def get_client_id_from_headers() -> SecretString | None:
|
|
82
|
+
"""Extract client ID from HTTP headers.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The client ID as a SecretString, or None if not found or not in HTTP context.
|
|
86
|
+
"""
|
|
87
|
+
headers = get_http_headers()
|
|
88
|
+
if not headers:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
value = _get_header_value(headers, HEADER_AIRBYTE_CLOUD_CLIENT_ID)
|
|
92
|
+
if value:
|
|
93
|
+
return SecretString(value)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_client_secret_from_headers() -> SecretString | None:
|
|
98
|
+
"""Extract client secret from HTTP headers.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The client secret as a SecretString, or None if not found or not in HTTP context.
|
|
102
|
+
"""
|
|
103
|
+
headers = get_http_headers()
|
|
104
|
+
if not headers:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
value = _get_header_value(headers, HEADER_AIRBYTE_CLOUD_CLIENT_SECRET)
|
|
108
|
+
if value:
|
|
109
|
+
return SecretString(value)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_workspace_id_from_headers() -> str | None:
|
|
114
|
+
"""Extract workspace ID from HTTP headers.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The workspace ID, or None if not found or not in HTTP context.
|
|
118
|
+
"""
|
|
119
|
+
headers = get_http_headers()
|
|
120
|
+
if not headers:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
return _get_header_value(headers, HEADER_AIRBYTE_CLOUD_WORKSPACE_ID)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_api_url_from_headers() -> str | None:
|
|
127
|
+
"""Extract API URL from HTTP headers.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The API URL, or None if not found or not in HTTP context.
|
|
131
|
+
"""
|
|
132
|
+
headers = get_http_headers()
|
|
133
|
+
if not headers:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
return _get_header_value(headers, HEADER_AIRBYTE_CLOUD_API_URL)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_client_id() -> SecretString:
|
|
140
|
+
"""Resolve client ID from HTTP headers or environment variables.
|
|
141
|
+
|
|
142
|
+
Resolution order:
|
|
143
|
+
1. HTTP header X-Airbyte-Cloud-Client-Id
|
|
144
|
+
2. Environment variable AIRBYTE_CLOUD_CLIENT_ID (via PyAirbyte)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The resolved client ID as a SecretString.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
PyAirbyteSecretNotFoundError: If no client ID can be resolved.
|
|
151
|
+
"""
|
|
152
|
+
header_value = get_client_id_from_headers()
|
|
153
|
+
if header_value:
|
|
154
|
+
return header_value
|
|
155
|
+
|
|
156
|
+
return resolve_cloud_client_id()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_client_secret() -> SecretString:
|
|
160
|
+
"""Resolve client secret from HTTP headers or environment variables.
|
|
161
|
+
|
|
162
|
+
Resolution order:
|
|
163
|
+
1. HTTP header X-Airbyte-Cloud-Client-Secret
|
|
164
|
+
2. Environment variable AIRBYTE_CLOUD_CLIENT_SECRET (via PyAirbyte)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The resolved client secret as a SecretString.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
PyAirbyteSecretNotFoundError: If no client secret can be resolved.
|
|
171
|
+
"""
|
|
172
|
+
header_value = get_client_secret_from_headers()
|
|
173
|
+
if header_value:
|
|
174
|
+
return header_value
|
|
175
|
+
|
|
176
|
+
return resolve_cloud_client_secret()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve_workspace_id(workspace_id: str | None = None) -> str:
|
|
180
|
+
"""Resolve workspace ID from multiple sources.
|
|
181
|
+
|
|
182
|
+
Resolution order:
|
|
183
|
+
1. Explicit workspace_id parameter (if provided)
|
|
184
|
+
2. HTTP header X-Airbyte-Cloud-Workspace-Id
|
|
185
|
+
3. Environment variable AIRBYTE_CLOUD_WORKSPACE_ID (via PyAirbyte)
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
workspace_id: Optional explicit workspace ID.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The resolved workspace ID.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
PyAirbyteSecretNotFoundError: If no workspace ID can be resolved.
|
|
195
|
+
"""
|
|
196
|
+
if workspace_id is not None:
|
|
197
|
+
return workspace_id
|
|
198
|
+
|
|
199
|
+
header_value = get_workspace_id_from_headers()
|
|
200
|
+
if header_value:
|
|
201
|
+
return header_value
|
|
202
|
+
|
|
203
|
+
return resolve_cloud_workspace_id()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def resolve_api_url(api_url: str | None = None) -> str:
|
|
207
|
+
"""Resolve API URL from multiple sources.
|
|
208
|
+
|
|
209
|
+
Resolution order:
|
|
210
|
+
1. Explicit api_url parameter (if provided)
|
|
211
|
+
2. HTTP header X-Airbyte-Cloud-Api-Url
|
|
212
|
+
3. Environment variable / default (via PyAirbyte)
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
api_url: Optional explicit API URL.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
The resolved API URL.
|
|
219
|
+
"""
|
|
220
|
+
if api_url is not None:
|
|
221
|
+
return api_url
|
|
222
|
+
|
|
223
|
+
header_value = get_api_url_from_headers()
|
|
224
|
+
if header_value:
|
|
225
|
+
return header_value
|
|
226
|
+
|
|
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]]] = []
|