airbyte-agent-mcp 0.1.30__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.
Files changed (56) hide show
  1. airbyte_agent_mcp/__init__.py +0 -0
  2. airbyte_agent_mcp/__main__.py +26 -0
  3. airbyte_agent_mcp/_vendored/__init__.py +1 -0
  4. airbyte_agent_mcp/_vendored/connector_sdk/__init__.py +82 -0
  5. airbyte_agent_mcp/_vendored/connector_sdk/auth_strategies.py +1123 -0
  6. airbyte_agent_mcp/_vendored/connector_sdk/auth_template.py +135 -0
  7. airbyte_agent_mcp/_vendored/connector_sdk/connector_model_loader.py +938 -0
  8. airbyte_agent_mcp/_vendored/connector_sdk/constants.py +78 -0
  9. airbyte_agent_mcp/_vendored/connector_sdk/exceptions.py +23 -0
  10. airbyte_agent_mcp/_vendored/connector_sdk/executor/__init__.py +31 -0
  11. airbyte_agent_mcp/_vendored/connector_sdk/executor/hosted_executor.py +188 -0
  12. airbyte_agent_mcp/_vendored/connector_sdk/executor/local_executor.py +1504 -0
  13. airbyte_agent_mcp/_vendored/connector_sdk/executor/models.py +190 -0
  14. airbyte_agent_mcp/_vendored/connector_sdk/extensions.py +655 -0
  15. airbyte_agent_mcp/_vendored/connector_sdk/http/__init__.py +37 -0
  16. airbyte_agent_mcp/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  17. airbyte_agent_mcp/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
  18. airbyte_agent_mcp/_vendored/connector_sdk/http/config.py +98 -0
  19. airbyte_agent_mcp/_vendored/connector_sdk/http/exceptions.py +119 -0
  20. airbyte_agent_mcp/_vendored/connector_sdk/http/protocols.py +114 -0
  21. airbyte_agent_mcp/_vendored/connector_sdk/http/response.py +102 -0
  22. airbyte_agent_mcp/_vendored/connector_sdk/http_client.py +679 -0
  23. airbyte_agent_mcp/_vendored/connector_sdk/logging/__init__.py +11 -0
  24. airbyte_agent_mcp/_vendored/connector_sdk/logging/logger.py +264 -0
  25. airbyte_agent_mcp/_vendored/connector_sdk/logging/types.py +92 -0
  26. airbyte_agent_mcp/_vendored/connector_sdk/observability/__init__.py +11 -0
  27. airbyte_agent_mcp/_vendored/connector_sdk/observability/models.py +19 -0
  28. airbyte_agent_mcp/_vendored/connector_sdk/observability/redactor.py +81 -0
  29. airbyte_agent_mcp/_vendored/connector_sdk/observability/session.py +94 -0
  30. airbyte_agent_mcp/_vendored/connector_sdk/performance/__init__.py +6 -0
  31. airbyte_agent_mcp/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  32. airbyte_agent_mcp/_vendored/connector_sdk/performance/metrics.py +93 -0
  33. airbyte_agent_mcp/_vendored/connector_sdk/schema/__init__.py +75 -0
  34. airbyte_agent_mcp/_vendored/connector_sdk/schema/base.py +160 -0
  35. airbyte_agent_mcp/_vendored/connector_sdk/schema/components.py +238 -0
  36. airbyte_agent_mcp/_vendored/connector_sdk/schema/connector.py +131 -0
  37. airbyte_agent_mcp/_vendored/connector_sdk/schema/extensions.py +109 -0
  38. airbyte_agent_mcp/_vendored/connector_sdk/schema/operations.py +146 -0
  39. airbyte_agent_mcp/_vendored/connector_sdk/schema/security.py +213 -0
  40. airbyte_agent_mcp/_vendored/connector_sdk/secrets.py +182 -0
  41. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  42. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/config.py +32 -0
  43. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/events.py +58 -0
  44. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/tracker.py +151 -0
  45. airbyte_agent_mcp/_vendored/connector_sdk/types.py +239 -0
  46. airbyte_agent_mcp/_vendored/connector_sdk/utils.py +60 -0
  47. airbyte_agent_mcp/_vendored/connector_sdk/validation.py +822 -0
  48. airbyte_agent_mcp/config.py +97 -0
  49. airbyte_agent_mcp/connector_manager.py +340 -0
  50. airbyte_agent_mcp/models.py +147 -0
  51. airbyte_agent_mcp/registry_client.py +103 -0
  52. airbyte_agent_mcp/secret_manager.py +94 -0
  53. airbyte_agent_mcp/server.py +265 -0
  54. airbyte_agent_mcp-0.1.30.dist-info/METADATA +134 -0
  55. airbyte_agent_mcp-0.1.30.dist-info/RECORD +56 -0
  56. airbyte_agent_mcp-0.1.30.dist-info/WHEEL +4 -0
@@ -0,0 +1,97 @@
1
+ """Configuration loading and validation."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from airbyte_agent_mcp.models import Config, ConnectorType
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def load_connector_config(config_path: str = "configured_connectors.yaml") -> Config:
14
+ """Load and validate configuration from YAML file.
15
+
16
+ Args:
17
+ config_path: Path to configured_connectors.yaml file
18
+
19
+ Returns:
20
+ Validated Config object
21
+
22
+ Raises:
23
+ FileNotFoundError: If config file doesn't exist
24
+ ValueError: If config is invalid
25
+ """
26
+ path = Path(config_path)
27
+
28
+ if not path.exists():
29
+ raise FileNotFoundError(
30
+ f"Configuration file not found: {config_path}\nCreate a configured_connectors.yaml file with your connector definitions."
31
+ )
32
+
33
+ logger.info(f"Loading configuration from {config_path}")
34
+
35
+ with open(path) as f:
36
+ data = yaml.safe_load(f)
37
+
38
+ if not data:
39
+ raise ValueError(f"Configuration file is empty: {config_path}")
40
+
41
+ try:
42
+ config = Config(**data)
43
+ except Exception as e:
44
+ raise ValueError(f"Invalid configuration: {e}") from e
45
+
46
+ logger.info(f"Loaded {len(config.connectors)} connector(s): {', '.join(c.id for c in config.connectors)}")
47
+
48
+ return config
49
+
50
+
51
+ def validate_connectors(config: Config) -> list[str]:
52
+ """Validate that connectors are available.
53
+
54
+ For LOCAL connectors with path: Check if connector.yaml exists
55
+ For LOCAL connectors with connector_name: Skip validation (validated lazily on first use)
56
+ For HOSTED connectors: Not implemented
57
+
58
+ Args:
59
+ config: Configuration to validate
60
+
61
+ Returns:
62
+ List of error messages (empty if all valid)
63
+ """
64
+ errors = []
65
+
66
+ for connector in config.connectors:
67
+ logger.debug(f"Validating connector: {connector.id}")
68
+
69
+ if connector.type == ConnectorType.LOCAL:
70
+ if connector.path:
71
+ # Check if connector.yaml exists
72
+ connector_path = Path(connector.path)
73
+ if not connector_path.exists():
74
+ error = f"LOCAL connector '{connector.id}': File not found: {connector.path}"
75
+ errors.append(error)
76
+ logger.error(error)
77
+ else:
78
+ logger.debug(f"LOCAL connector '{connector.id}' file found")
79
+ elif connector.connector_name:
80
+ # Registry-based connector: validation happens lazily on first use
81
+ logger.debug(
82
+ f"LOCAL connector '{connector.id}' uses registry " f"(connector_name={connector.connector_name}), skipping startup validation"
83
+ )
84
+ elif connector.type == ConnectorType.HOSTED:
85
+ # HOSTED connectors are not implemented yet
86
+ error = f"HOSTED connector '{connector.id}' - not implemented"
87
+ errors.append(error)
88
+ logger.error(error)
89
+ else:
90
+ error = f"Unknown connector type '{connector.type}' found"
91
+ errors.append(error)
92
+ logger.error(error)
93
+
94
+ if not errors:
95
+ logger.info("All connectors validated successfully")
96
+
97
+ return errors
@@ -0,0 +1,340 @@
1
+ """Connector instantiation and execution management."""
2
+
3
+ import base64
4
+ import inspect
5
+ import logging
6
+ from typing import Any
7
+
8
+ from ._vendored.connector_sdk import LocalExecutor as ConnectorExecutor
9
+ from ._vendored.connector_sdk.connector_model_loader import load_connector_model
10
+ from ._vendored.connector_sdk.executor.models import ExecutionConfig
11
+
12
+ from airbyte_agent_mcp.models import Config, ConnectorConfig, ConnectorInfo, ConnectorType, DiscoverConnectorsResponse
13
+ from airbyte_agent_mcp.registry_client import RegistryClient
14
+ from airbyte_agent_mcp.secret_manager import SecretsManager
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ConnectorManager:
20
+ """Manages connector lifecycle and execution."""
21
+
22
+ def __init__(
23
+ self,
24
+ config: Config,
25
+ secrets_manager: SecretsManager,
26
+ registry_client: RegistryClient | None = None,
27
+ ):
28
+ """Initialize manager.
29
+
30
+ Args:
31
+ config: Configuration with connector definitions
32
+ secrets_manager: Secrets manager for resolving credentials
33
+ registry_client: Optional registry client for fetching remote connectors
34
+ """
35
+ self.config = config
36
+ self.secrets_manager = secrets_manager
37
+ self.registry_client = registry_client or RegistryClient()
38
+
39
+ async def _get_connector_path(self, connector_config: ConnectorConfig) -> str:
40
+ """Get path to connector.yaml (local file or downloaded from registry).
41
+
42
+ Args:
43
+ connector_config: The connector configuration
44
+
45
+ Returns:
46
+ Path to the connector.yaml file
47
+ """
48
+ if connector_config.path:
49
+ # Use local path
50
+ return connector_config.path
51
+
52
+ # Download from registry
53
+ path = await self.registry_client.download_connector(
54
+ connector_name=connector_config.connector_name,
55
+ version=connector_config.version,
56
+ )
57
+ return str(path)
58
+
59
+ async def execute(
60
+ self,
61
+ connector_id: str,
62
+ entity: str,
63
+ action: str,
64
+ params: dict[str, Any] | None = None,
65
+ ) -> dict[str, Any]:
66
+ """Execute an operation on a connector.
67
+
68
+ This is stateless - creates a fresh connector instance for each call.
69
+
70
+ Args:
71
+ connector_id: Connector ID from config
72
+ entity: Entity name (e.g., "customers")
73
+ action: Operation action (e.g., "list", "get", "create")
74
+ params: Operation parameters (optional)
75
+
76
+ Returns:
77
+ Result from connector execution
78
+
79
+ Raises:
80
+ ValueError: If connector not found
81
+ Exception: Any error from connector execution
82
+ """
83
+ params = params or {}
84
+
85
+ logger.info(f"Executing: {connector_id}.{entity}.{action} with params: {list(params.keys())}")
86
+
87
+ connector_config = self.config.get_connector(connector_id)
88
+
89
+ secrets = {}
90
+ if connector_config.secrets:
91
+ secrets = self.secrets_manager.get_secrets(connector_config.secrets)
92
+
93
+ # Resolve config_values from environment variables
94
+ config_values = {}
95
+ if connector_config.config_values:
96
+ config_values = self.secrets_manager.get_secrets(connector_config.config_values)
97
+
98
+ # Get path (local or from registry)
99
+ path = await self._get_connector_path(connector_config)
100
+ logger.info(f"Using connector path: {path}")
101
+ connector = self._create_yaml_connector(path, secrets, auth_scheme=connector_config.auth_scheme, config_values=config_values)
102
+
103
+ logger.debug(f"Calling connector.execute({entity}, {action}, ...)")
104
+ result = await connector.execute(ExecutionConfig(entity=entity, action=action, params=params))
105
+
106
+ # Handle ExecutionResult from SDK
107
+ if not result.success:
108
+ raise Exception(result.error or "Execution failed")
109
+
110
+ # Handle download operations (data is AsyncIterator[bytes])
111
+ if inspect.isasyncgen(result.data):
112
+ return await self._handle_download(result.data)
113
+
114
+ logger.info("Execution successful")
115
+ return result.data
116
+
117
+ def _create_yaml_connector(
118
+ self, path: str, secrets: dict[str, Any], auth_scheme: str | None = None, config_values: dict[str, str] | None = None
119
+ ) -> Any:
120
+ """Create a YAML-based connector instance.
121
+
122
+ Args:
123
+ path: Path to connector.yaml file
124
+ secrets: Resolved secrets dict
125
+ auth_scheme: Auth scheme for multi-auth connectors (optional)
126
+ config_values: Non-secret config values like subdomain, region (optional)
127
+
128
+ Returns:
129
+ ConnectorExecutor instance
130
+ """
131
+ connector = ConnectorExecutor(
132
+ config_path=path, auth_config=secrets, auth_scheme=auth_scheme, config_values=config_values, execution_context="mcp"
133
+ )
134
+
135
+ return connector
136
+
137
+ async def _handle_download(self, stream: Any) -> dict[str, Any]:
138
+ """Handle download operations by collecting bytes and base64 encoding.
139
+
140
+ Args:
141
+ stream: AsyncIterator[bytes] from connector download operation
142
+
143
+ Returns:
144
+ Dict with base64-encoded data and metadata
145
+
146
+ Raises:
147
+ Exception: If file exceeds maximum size limit
148
+ """
149
+ max_size = 50 * 1024 * 1024 # 50MB limit for MCP downloads
150
+ chunks: list[bytes] = []
151
+ total_size = 0
152
+
153
+ async for chunk in stream:
154
+ total_size += len(chunk)
155
+ if total_size > max_size:
156
+ raise Exception(
157
+ f"Download exceeds maximum size limit ({max_size // (1024 * 1024)}MB). Use the SDK directly for large file downloads."
158
+ )
159
+ chunks.append(chunk)
160
+
161
+ binary_data = b"".join(chunks)
162
+
163
+ logger.info(f"Download successful: {len(binary_data)} bytes")
164
+
165
+ return {
166
+ "data": base64.b64encode(binary_data).decode("utf-8"),
167
+ "size": len(binary_data),
168
+ "encoding": "base64",
169
+ }
170
+
171
+ async def describe_connector(self, connector_id: str) -> list[dict[str, Any]]:
172
+ """List available entities for a connector.
173
+
174
+ Args:
175
+ connector_id: Connector ID from config
176
+
177
+ Returns:
178
+ List of entity info dicts
179
+ """
180
+ logger.info(f"Listing entities for: {connector_id}")
181
+
182
+ connector_config = self.config.get_connector(connector_id)
183
+ if connector_config.type == ConnectorType.LOCAL:
184
+ path = await self._get_connector_path(connector_config)
185
+ return await self._describe_connector_from_sdk(path)
186
+
187
+ async def _describe_connector_from_sdk(self, path: str) -> list[dict[str, Any]]:
188
+ """List entities from a YAML connector definition.
189
+
190
+ Args:
191
+ path: Path to connector.yaml (OpenAPI spec)
192
+
193
+ Returns:
194
+ List of entity info dicts
195
+ """
196
+
197
+ # Load and parse the connector model using SDK
198
+ connector_model = load_connector_model(path)
199
+
200
+ # Build parameter lookup from OpenAPI spec for full metadata
201
+ param_lookup = self._build_param_lookup(connector_model)
202
+
203
+ entities = []
204
+ for entity_def in connector_model.entities:
205
+ description = ""
206
+ parameters: dict[str, list[dict[str, Any]]] = {}
207
+
208
+ # Extract parameters for each action from endpoints
209
+ if entity_def.endpoints:
210
+ for action, endpoint in entity_def.endpoints.items():
211
+ if not description and endpoint.description:
212
+ description = endpoint.description
213
+
214
+ # Get the lookup key for this endpoint
215
+ lookup_key = (endpoint.path, endpoint.method.lower())
216
+ operation_params = param_lookup.get(lookup_key, {})
217
+
218
+ # Collect all parameters for this action with full metadata
219
+ action_params = []
220
+
221
+ # Path params (always required)
222
+ for param_name in endpoint.path_params:
223
+ param_meta = operation_params.get(param_name, {})
224
+ action_params.append(
225
+ {
226
+ "name": param_name,
227
+ "in": "path",
228
+ "required": True, # Path params are always required
229
+ "type": param_meta.get("type", "string"),
230
+ "description": param_meta.get("description", ""),
231
+ }
232
+ )
233
+
234
+ # Query params (get required/type/description from spec)
235
+ for param_name in endpoint.query_params:
236
+ param_meta = operation_params.get(param_name, {})
237
+ action_params.append(
238
+ {
239
+ "name": param_name,
240
+ "in": "query",
241
+ "required": param_meta.get("required", False),
242
+ "type": param_meta.get("type", "string"),
243
+ "description": param_meta.get("description", ""),
244
+ }
245
+ )
246
+
247
+ # Body fields (get required from request schema)
248
+ request_schema = endpoint.request_schema or {}
249
+ required_fields = request_schema.get("required", [])
250
+ properties = request_schema.get("properties", {})
251
+
252
+ for param_name in endpoint.body_fields:
253
+ prop = properties.get(param_name, {})
254
+ action_params.append(
255
+ {
256
+ "name": param_name,
257
+ "in": "body",
258
+ "required": param_name in required_fields,
259
+ "type": prop.get("type", "string"),
260
+ "description": prop.get("description", ""),
261
+ }
262
+ )
263
+
264
+ if action_params:
265
+ parameters[action.value] = action_params
266
+
267
+ # Convert Action enums to strings
268
+ available_actions = [action.value for action in entity_def.actions]
269
+
270
+ entities.append(
271
+ {
272
+ "entity_name": entity_def.name,
273
+ "description": description,
274
+ "available_actions": available_actions,
275
+ "parameters": parameters,
276
+ }
277
+ )
278
+
279
+ return entities
280
+
281
+ def _build_param_lookup(self, connector_model: Any) -> dict[tuple[str, str], dict[str, dict[str, Any]]]:
282
+ """Build a lookup of parameter metadata from OpenAPI spec.
283
+
284
+ Args:
285
+ connector_model: Loaded connector model with openapi_spec
286
+
287
+ Returns:
288
+ Dict mapping (path, method) -> {param_name -> {type, description, required}}
289
+ """
290
+ lookup: dict[tuple[str, str], dict[str, dict[str, Any]]] = {}
291
+
292
+ openapi_spec = getattr(connector_model, "openapi_spec", None)
293
+ if not openapi_spec or not hasattr(openapi_spec, "paths"):
294
+ return lookup
295
+
296
+ for path, path_item in openapi_spec.paths.items():
297
+ for method_name in ["get", "post", "put", "delete", "patch"]:
298
+ operation = getattr(path_item, method_name, None)
299
+ if not operation:
300
+ continue
301
+
302
+ param_map: dict[str, dict[str, Any]] = {}
303
+ if operation.parameters:
304
+ for param in operation.parameters:
305
+ # Extract type from schema if available
306
+ param_type = "string"
307
+ if param.schema_:
308
+ param_type = param.schema_.get("type", "string")
309
+
310
+ param_map[param.name] = {
311
+ "type": param_type,
312
+ "description": param.description or "",
313
+ "required": param.required or False,
314
+ }
315
+
316
+ lookup[(path, method_name)] = param_map
317
+
318
+ return lookup
319
+
320
+ def discover_connectors(self) -> dict[str, Any]:
321
+ """Discover all available configured connectors.
322
+
323
+ Returns:
324
+ Dictionary with list of connector information
325
+ """
326
+ logger.info("Discovering connectors from configuration")
327
+
328
+ # Build list of connector info
329
+ connector_infos = [
330
+ ConnectorInfo(
331
+ id=connector.id,
332
+ type=connector.type.value, # Convert enum to string
333
+ description=connector.description,
334
+ )
335
+ for connector in self.config.connectors
336
+ ]
337
+
338
+ response = DiscoverConnectorsResponse(connectors=connector_infos)
339
+
340
+ return response.model_dump()
@@ -0,0 +1,147 @@
1
+ """Data models for airbyte-agent-mcp configuration and responses."""
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class BackoffType(str, Enum):
10
+ """Retry backoff strategy."""
11
+
12
+ EXPONENTIAL = "exponential"
13
+ LINEAR = "linear"
14
+
15
+
16
+ class ErrorType(str, Enum):
17
+ """Categorized error types for retry logic."""
18
+
19
+ RATE_LIMIT = "rate_limit"
20
+ TIMEOUT = "timeout"
21
+ SERVER_ERROR = "server_error"
22
+ UNKNOWN = "unknown"
23
+
24
+
25
+ class ConnectorType(str, Enum):
26
+ """Type of connector."""
27
+
28
+ LOCAL = "local"
29
+ HOSTED = "hosted"
30
+
31
+
32
+ class ConnectorConfig(BaseModel):
33
+ """Configuration for a single connector."""
34
+
35
+ id: str = Field(..., description="Unique connector identifier")
36
+ type: ConnectorType = Field(..., description="Connector type")
37
+ path: str | None = Field(None, description="Path to connector.yaml (optional if connector_name provided)")
38
+ connector_name: str | None = Field(None, description="Connector name in registry")
39
+ version: str | None = Field(None, description="Version to pin (optional, defaults to latest)")
40
+ description: str = Field(default="", description="Human-readable description")
41
+ secrets: dict[str, str] = Field(default_factory=dict, description="Mapping of secret param names to secret keys")
42
+ config_values: dict[str, str] = Field(default_factory=dict, description="Non-secret config values (e.g., subdomain, region)")
43
+ auth_scheme: str | None = Field(None, description="Auth scheme for multi-auth connectors (e.g., 'oauth', 'api_token')")
44
+
45
+ def model_post_init(self, __context):
46
+ """Validate connector-type-specific fields."""
47
+ if self.type == ConnectorType.LOCAL:
48
+ if not self.path and not self.connector_name:
49
+ raise ValueError(f"LOCAL connector '{self.id}' must specify either 'path' or 'connector_name'")
50
+
51
+ # Version pinning only makes sense with registry-based connectors
52
+ if self.path and self.version:
53
+ raise ValueError(
54
+ f"LOCAL connector '{self.id}' uses 'path' and 'version'. "
55
+ "Version pinning is only supported for registry-based connectors "
56
+ "using 'connector_name'. Remove 'version' or switch to 'connector_name'."
57
+ )
58
+
59
+
60
+ class Config(BaseModel):
61
+ """Root configuration model."""
62
+
63
+ connectors: list[ConnectorConfig] = Field(..., min_length=1)
64
+
65
+ def get_connector(self, connector_id: str) -> ConnectorConfig:
66
+ """Look up connector by ID."""
67
+ for connector in self.connectors:
68
+ if connector.id == connector_id:
69
+ return connector
70
+ raise ValueError(f"Connector not found: {connector_id}")
71
+
72
+
73
+ class ExecuteResponse(BaseModel):
74
+ """Response from execute tool."""
75
+
76
+ success: bool
77
+ data: Any = None
78
+ error: dict | None = None
79
+ connector_id: str
80
+ entity: str
81
+ action: str
82
+
83
+
84
+ class ParameterInfo(BaseModel):
85
+ """Information about an operation parameter."""
86
+
87
+ name: str = Field(..., description="Parameter name")
88
+ in_: str = Field(..., alias="in", description="Parameter location: path, query, or body")
89
+ required: bool = Field(default=False, description="Whether the parameter is required")
90
+ type: str = Field(default="string", description="Parameter type (string, integer, boolean, etc.)")
91
+ description: str = Field(default="", description="Parameter description")
92
+
93
+
94
+ class EntityInfo(BaseModel):
95
+ """Information about a connector entity."""
96
+
97
+ entity_name: str = Field(..., description="Entity name to use in execute() calls")
98
+ description: str = ""
99
+ available_actions: list[str] = Field(default_factory=list)
100
+ parameters: dict[str, list[dict[str, Any]]] = Field(
101
+ default_factory=dict,
102
+ description="Parameters for each action, keyed by action name. Each parameter has: name, in (path/query/body), required, type, description",
103
+ )
104
+
105
+
106
+ class ListEntitiesResponse(BaseModel):
107
+ """Response from list_entities tool."""
108
+
109
+ connector_id: str
110
+ entities: list[EntityInfo]
111
+
112
+
113
+ class DescribeEntityResponse(BaseModel):
114
+ """Response from describe_entity tool."""
115
+
116
+ connector_id: str
117
+ entity: str
118
+ actions: dict
119
+ schema: dict
120
+
121
+
122
+ class ValidationError(BaseModel):
123
+ """Validation error detail."""
124
+
125
+ field: str
126
+ message: str
127
+
128
+
129
+ class ValidateOperationResponse(BaseModel):
130
+ """Response from validate_operation tool."""
131
+
132
+ valid: bool
133
+ errors: list[ValidationError] = Field(default_factory=list)
134
+
135
+
136
+ class ConnectorInfo(BaseModel):
137
+ """Information about a configured connector."""
138
+
139
+ id: str = Field(..., description="Connector identifier")
140
+ type: str = Field(..., description="Connector type (local or remote)")
141
+ description: str = Field(default="", description="Human-readable description")
142
+
143
+
144
+ class DiscoverConnectorsResponse(BaseModel):
145
+ """Response from discover_connectors tool."""
146
+
147
+ connectors: list[ConnectorInfo] = Field(..., description="List of configured connectors")
@@ -0,0 +1,103 @@
1
+ """Client for fetching connectors from the Airbyte registry."""
2
+
3
+ import atexit
4
+ import logging
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ REGISTRY_BASE_URL = "https://connectors.airbyte.ai"
15
+ REGISTRY_JSON_URL = f"{REGISTRY_BASE_URL}/registry.json"
16
+
17
+
18
+ class RegistryClient:
19
+ """Client for the Airbyte connector registry.
20
+
21
+ Downloads connectors fresh from registry on each use (no caching).
22
+ Uses temp directory for downloaded connector.yaml files.
23
+ """
24
+
25
+ def __init__(self):
26
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="airbyte-connectors-"))
27
+ atexit.register(self._cleanup)
28
+
29
+ def _cleanup(self):
30
+ """Clean up temp directory on exit."""
31
+ if self.temp_dir.exists():
32
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
33
+
34
+ async def fetch_registry(self) -> dict[str, Any]:
35
+ """Fetch the registry.json index (always fresh)."""
36
+ async with httpx.AsyncClient(timeout=30.0) as client:
37
+ response = await client.get(REGISTRY_JSON_URL)
38
+ response.raise_for_status()
39
+ return response.json()
40
+
41
+ async def resolve_connector_url(
42
+ self,
43
+ connector_name: str,
44
+ version: str | None = None,
45
+ ) -> str:
46
+ """Resolve connector to a download URL.
47
+
48
+ Args:
49
+ connector_name: Connector name (e.g., "stripe")
50
+ version: Specific version or None for latest
51
+
52
+ Returns:
53
+ URL to download connector.yaml
54
+ """
55
+ registry = await self.fetch_registry()
56
+
57
+ # Find connector by name
58
+ connector = None
59
+ for c in registry["connectors"]:
60
+ if c["connector_name"] == connector_name:
61
+ connector = c
62
+ break
63
+
64
+ if not connector:
65
+ raise ValueError(f"Connector not found in registry: {connector_name}")
66
+
67
+ # Get version URL
68
+ if version:
69
+ for v in connector["versions"]:
70
+ if v["version"] == version:
71
+ return v["url"]
72
+ raise ValueError(f"Version {version} not found for {connector['connector_name']}")
73
+
74
+ return connector["latest_url"]
75
+
76
+ async def download_connector(
77
+ self,
78
+ connector_name: str,
79
+ version: str | None = None,
80
+ ) -> Path:
81
+ """Download connector.yaml and return path (cached within session)."""
82
+ url = await self.resolve_connector_url(connector_name, version)
83
+
84
+ # Create temp path based on URL
85
+ parts = url.replace(f"{REGISTRY_BASE_URL}/definitions/", "").split("/")
86
+ name, ver = parts[0], parts[1]
87
+ temp_path = self.temp_dir / name / ver / "connector.yaml"
88
+
89
+ # Return cached version if already downloaded
90
+ if temp_path.exists():
91
+ logger.debug(f"Using cached connector: {temp_path}")
92
+ return temp_path
93
+
94
+ # Download fresh
95
+ logger.info(f"Downloading connector from: {url}")
96
+ async with httpx.AsyncClient(timeout=30.0) as client:
97
+ response = await client.get(url)
98
+ response.raise_for_status()
99
+
100
+ temp_path.parent.mkdir(parents=True, exist_ok=True)
101
+ temp_path.write_text(response.text)
102
+
103
+ return temp_path