airbyte-internal-ops 0.5.1__py3-none-any.whl → 0.6.0__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.
@@ -1,51 +0,0 @@
1
- # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
- """MCP tool annotation constants.
3
-
4
- These constants define the standard MCP annotations for tools, following the
5
- FastMCP 2.2.7+ specification.
6
-
7
- For more information, see:
8
- https://gofastmcp.com/concepts/tools#mcp-annotations
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- READ_ONLY_HINT = "readOnlyHint"
14
- """Indicates if the tool only reads data without making any changes.
15
-
16
- When True, the tool performs read-only operations and does not modify any state.
17
- When False, the tool may write, create, update, or delete data.
18
-
19
- FastMCP default if not specified: False
20
- """
21
-
22
- DESTRUCTIVE_HINT = "destructiveHint"
23
- """Signals if the tool's changes are destructive (updates or deletes existing data).
24
-
25
- This hint is only relevant for non-read-only tools (readOnlyHint=False).
26
- When True, the tool modifies or deletes existing data in a way that may be
27
- difficult or impossible to reverse.
28
- When False, the tool creates new data or performs non-destructive operations.
29
-
30
- FastMCP default if not specified: True
31
- """
32
-
33
- IDEMPOTENT_HINT = "idempotentHint"
34
- """Indicates if repeated calls with the same parameters have the same effect.
35
-
36
- When True, calling the tool multiple times with identical parameters produces
37
- the same result and side effects as calling it once.
38
- When False, each call may produce different results or side effects.
39
-
40
- FastMCP default if not specified: False
41
- """
42
-
43
- OPEN_WORLD_HINT = "openWorldHint"
44
- """Specifies if the tool interacts with external systems.
45
-
46
- When True, the tool communicates with external services, APIs, or systems
47
- outside the local environment (e.g., cloud APIs, remote databases, internet).
48
- When False, the tool only operates on local state or resources.
49
-
50
- FastMCP default if not specified: True
51
- """
@@ -1,254 +0,0 @@
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
@@ -1,398 +0,0 @@
1
- # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
- """Deferred MCP capability registration for tools, prompts, and resources.
3
-
4
- This module provides a decorator to tag tool functions with MCP annotations
5
- for deferred registration. The domain for each tool is automatically derived
6
- from the file stem of the module where the tool is defined.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import inspect
12
- from collections.abc import Callable
13
- from dataclasses import dataclass
14
- from enum import Enum
15
- from pathlib import Path
16
- from typing import Any, TypeVar
17
-
18
- from fastmcp import FastMCP
19
-
20
- from airbyte_ops_mcp._annotations import (
21
- DESTRUCTIVE_HINT,
22
- IDEMPOTENT_HINT,
23
- OPEN_WORLD_HINT,
24
- READ_ONLY_HINT,
25
- )
26
-
27
- F = TypeVar("F", bound=Callable[..., Any])
28
-
29
-
30
- @dataclass
31
- class PromptDef:
32
- """Definition of a deferred MCP prompt."""
33
-
34
- name: str
35
- description: str
36
- func: Callable[..., list[dict[str, str]]]
37
-
38
-
39
- @dataclass
40
- class ResourceDef:
41
- """Definition of a deferred MCP resource."""
42
-
43
- uri: str
44
- description: str
45
- mime_type: str
46
- func: Callable[..., Any]
47
-
48
-
49
- class ToolDomain(str, Enum):
50
- """Tool domain categories for the Airbyte Admin MCP server.
51
-
52
- These domains correspond to the main functional areas of the server.
53
- """
54
-
55
- REGISTRY = "registry"
56
- """Registry tools for connector registry operations"""
57
-
58
- METADATA = "metadata"
59
- """Metadata tools for connector metadata operations"""
60
-
61
- QA = "qa"
62
- """QA tools for connector quality assurance"""
63
-
64
- INSIGHTS = "insights"
65
- """Insights tools for connector analysis and insights"""
66
-
67
- REPO = "repo"
68
- """Repository tools for GitHub repository operations"""
69
-
70
- CLOUD_ADMIN = "cloud_admin"
71
- """Cloud admin tools for Airbyte Cloud operations"""
72
-
73
- SERVER_INFO = "server_info"
74
- """Server information and version resources"""
75
-
76
- PROMPTS = "prompts"
77
- """Prompt templates for common workflows"""
78
-
79
- REGRESSION_TESTS = "regression_tests"
80
- """Regression tests for connector validation and comparison testing"""
81
-
82
-
83
- _REGISTERED_TOOLS: list[tuple[Callable[..., Any], dict[str, Any]]] = []
84
- _REGISTERED_RESOURCES: list[tuple[Callable[..., Any], dict[str, Any]]] = []
85
- _REGISTERED_PROMPTS: list[tuple[Callable[..., Any], dict[str, Any]]] = []
86
-
87
-
88
- def should_register_tool(annotations: dict[str, Any]) -> bool:
89
- """Check if a tool should be registered.
90
-
91
- Args:
92
- annotations: Tool annotations dict
93
-
94
- Returns:
95
- Always returns True (no filtering applied)
96
- """
97
- return True
98
-
99
-
100
- def _get_caller_file_stem() -> str:
101
- """Get the file stem of the caller's module.
102
-
103
- Walks up the call stack to find the first frame outside this module,
104
- then returns the stem of that file (e.g., "github" for "github.py").
105
-
106
- Returns:
107
- The file stem of the calling module.
108
- """
109
- for frame_info in inspect.stack():
110
- # Skip frames from this module
111
- if frame_info.filename != __file__:
112
- return Path(frame_info.filename).stem
113
- return "unknown"
114
-
115
-
116
- def _normalize_domain(domain: str) -> str:
117
- """Normalize a domain string to its simple form.
118
-
119
- Handles both file stems (e.g., "github") and module names
120
- (e.g., "airbyte_ops_mcp.mcp.github") by extracting the last segment.
121
-
122
- Args:
123
- domain: A domain string, either a simple name or a dotted module path.
124
-
125
- Returns:
126
- The normalized domain (last segment of a dotted path, or the input if no dots).
127
- """
128
- return domain.rsplit(".", 1)[-1]
129
-
130
-
131
- def mcp_tool(
132
- domain: ToolDomain | str | None = None,
133
- *,
134
- read_only: bool = False,
135
- destructive: bool = False,
136
- idempotent: bool = False,
137
- open_world: bool = False,
138
- extra_help_text: str | None = None,
139
- ) -> Callable[[F], F]:
140
- """Decorator to tag an MCP tool function with annotations for deferred registration.
141
-
142
- This decorator stores the annotations on the function for later use during
143
- deferred registration. It does not register the tool immediately.
144
-
145
- The domain is automatically derived from the file stem of the module where
146
- the tool is defined (e.g., tools in "github.py" get domain "github").
147
-
148
- Args:
149
- domain: Optional explicit domain override. If not provided, the domain
150
- is automatically derived from the caller's file stem.
151
- read_only: If True, tool only reads without making changes (default: False)
152
- destructive: If True, tool modifies/deletes existing data (default: False)
153
- idempotent: If True, repeated calls have same effect (default: False)
154
- open_world: If True, tool interacts with external systems (default: False)
155
- extra_help_text: Optional text to append to the function's docstring
156
- with a newline delimiter
157
-
158
- Returns:
159
- Decorator function that tags the tool with annotations
160
-
161
- Example:
162
- @mcp_tool(read_only=True, idempotent=True)
163
- def list_connectors_in_repo():
164
- ...
165
- """
166
- # Auto-derive domain from caller's file stem if not provided
167
- if domain is None:
168
- domain_str = _get_caller_file_stem()
169
- elif isinstance(domain, ToolDomain):
170
- domain_str = domain.value
171
- else:
172
- domain_str = domain
173
-
174
- annotations: dict[str, Any] = {
175
- "domain": domain_str,
176
- READ_ONLY_HINT: read_only,
177
- DESTRUCTIVE_HINT: destructive,
178
- IDEMPOTENT_HINT: idempotent,
179
- OPEN_WORLD_HINT: open_world,
180
- }
181
-
182
- def decorator(func: F) -> F:
183
- if extra_help_text:
184
- func.__doc__ = (
185
- (func.__doc__ or "") + "\n\n" + (extra_help_text or "")
186
- ).rstrip()
187
-
188
- _REGISTERED_TOOLS.append((func, annotations))
189
- return func
190
-
191
- return decorator
192
-
193
-
194
- def mcp_prompt(
195
- name: str,
196
- description: str,
197
- domain: ToolDomain | str | None = None,
198
- ):
199
- """Decorator for deferred MCP prompt registration.
200
-
201
- Args:
202
- name: Unique name for the prompt
203
- description: Human-readable description of the prompt
204
- domain: Optional domain for filtering. If not provided, automatically
205
- derived from the caller's file stem.
206
-
207
- Returns:
208
- Decorator function that registers the prompt
209
-
210
- Raises:
211
- ValueError: If a prompt with the same name is already registered
212
- """
213
- # Auto-derive domain from caller's file stem if not provided
214
- if domain is None:
215
- domain_str = _get_caller_file_stem()
216
- elif isinstance(domain, ToolDomain):
217
- domain_str = domain.value
218
- else:
219
- domain_str = domain
220
-
221
- def decorator(func: Callable[..., list[dict[str, str]]]):
222
- annotations = {
223
- "name": name,
224
- "description": description,
225
- "domain": domain_str,
226
- }
227
- _REGISTERED_PROMPTS.append((func, annotations))
228
- return func
229
-
230
- return decorator
231
-
232
-
233
- def mcp_resource(
234
- uri: str,
235
- description: str,
236
- mime_type: str,
237
- domain: ToolDomain | str | None = None,
238
- ):
239
- """Decorator for deferred MCP resource registration.
240
-
241
- Args:
242
- uri: Unique URI for the resource
243
- description: Human-readable description of the resource
244
- mime_type: MIME type of the resource content
245
- domain: Optional domain for filtering. If not provided, automatically
246
- derived from the caller's file stem.
247
-
248
- Returns:
249
- Decorator function that registers the resource
250
-
251
- Raises:
252
- ValueError: If a resource with the same URI is already registered
253
- """
254
- # Auto-derive domain from caller's file stem if not provided
255
- if domain is None:
256
- domain_str = _get_caller_file_stem()
257
- elif isinstance(domain, ToolDomain):
258
- domain_str = domain.value
259
- else:
260
- domain_str = domain
261
-
262
- def decorator(func: Callable[..., Any]):
263
- annotations = {
264
- "uri": uri,
265
- "description": description,
266
- "mime_type": mime_type,
267
- "domain": domain_str,
268
- }
269
- _REGISTERED_RESOURCES.append((func, annotations))
270
- return func
271
-
272
- return decorator
273
-
274
-
275
- def _register_mcp_callables(
276
- *,
277
- app: FastMCP,
278
- domain: ToolDomain | str,
279
- resource_list: list[tuple[Callable, dict]],
280
- register_fn: Callable,
281
- ) -> None:
282
- """Register resources and tools with the FastMCP app, filtered by domain.
283
-
284
- Args:
285
- app: The FastMCP app instance
286
- domain: The domain to register tools for. Can be a simple name (e.g., "github")
287
- or a full module path (e.g., "airbyte_ops_mcp.mcp.github" from __name__).
288
- resource_list: List of (callable, annotations) tuples to register
289
- register_fn: Function to call for each registration
290
- """
291
- domain_str = domain.value if isinstance(domain, ToolDomain) else domain
292
- # Normalize to handle both file stems and __name__ module paths
293
- domain_str = _normalize_domain(domain_str)
294
-
295
- filtered_callables = [
296
- (func, ann) for func, ann in resource_list if ann.get("domain") == domain_str
297
- ]
298
-
299
- for callable_fn, callable_annotations in filtered_callables:
300
- register_fn(app, callable_fn, callable_annotations)
301
-
302
-
303
- def register_mcp_tools(
304
- app: FastMCP,
305
- domain: ToolDomain | str | None = None,
306
- ) -> None:
307
- """Register tools with the FastMCP app, filtered by domain.
308
-
309
- Args:
310
- app: The FastMCP app instance
311
- domain: The domain to register for. If not provided, automatically
312
- derived from the caller's file stem.
313
- """
314
- if domain is None:
315
- domain = _get_caller_file_stem()
316
-
317
- def _register_fn(
318
- app: FastMCP,
319
- callable_fn: Callable,
320
- annotations: dict[str, Any],
321
- ):
322
- app.tool(
323
- callable_fn,
324
- annotations=annotations,
325
- )
326
-
327
- _register_mcp_callables(
328
- app=app,
329
- domain=domain,
330
- resource_list=_REGISTERED_TOOLS,
331
- register_fn=_register_fn,
332
- )
333
-
334
-
335
- def register_mcp_prompts(
336
- app: FastMCP,
337
- domain: ToolDomain | str | None = None,
338
- ) -> None:
339
- """Register prompt callables with the FastMCP app, filtered by domain.
340
-
341
- Args:
342
- app: The FastMCP app instance
343
- domain: The domain to register for. If not provided, automatically
344
- derived from the caller's file stem.
345
- """
346
- if domain is None:
347
- domain = _get_caller_file_stem()
348
-
349
- def _register_fn(
350
- app: FastMCP,
351
- callable_fn: Callable,
352
- annotations: dict[str, Any],
353
- ):
354
- app.prompt(
355
- name=annotations["name"],
356
- description=annotations["description"],
357
- )(callable_fn)
358
-
359
- _register_mcp_callables(
360
- app=app,
361
- domain=domain,
362
- resource_list=_REGISTERED_PROMPTS,
363
- register_fn=_register_fn,
364
- )
365
-
366
-
367
- def register_mcp_resources(
368
- app: FastMCP,
369
- domain: ToolDomain | str | None = None,
370
- ) -> None:
371
- """Register resource callables with the FastMCP app, filtered by domain.
372
-
373
- Args:
374
- app: The FastMCP app instance
375
- domain: The domain to register for. If not provided, automatically
376
- derived from the caller's file stem.
377
- """
378
- if domain is None:
379
- domain = _get_caller_file_stem()
380
-
381
- def _register_fn(
382
- app: FastMCP,
383
- callable_fn: Callable,
384
- annotations: dict[str, Any],
385
- ):
386
- _ = annotations
387
- app.resource(
388
- annotations["uri"],
389
- description=annotations["description"],
390
- mime_type=annotations["mime_type"],
391
- )(callable_fn)
392
-
393
- _register_mcp_callables(
394
- app=app,
395
- domain=domain,
396
- resource_list=_REGISTERED_RESOURCES,
397
- register_fn=_register_fn,
398
- )