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.
- airbyte_agent_mcp/__init__.py +0 -0
- airbyte_agent_mcp/__main__.py +26 -0
- airbyte_agent_mcp/_vendored/__init__.py +1 -0
- airbyte_agent_mcp/_vendored/connector_sdk/__init__.py +82 -0
- airbyte_agent_mcp/_vendored/connector_sdk/auth_strategies.py +1123 -0
- airbyte_agent_mcp/_vendored/connector_sdk/auth_template.py +135 -0
- airbyte_agent_mcp/_vendored/connector_sdk/connector_model_loader.py +938 -0
- airbyte_agent_mcp/_vendored/connector_sdk/constants.py +78 -0
- airbyte_agent_mcp/_vendored/connector_sdk/exceptions.py +23 -0
- airbyte_agent_mcp/_vendored/connector_sdk/executor/__init__.py +31 -0
- airbyte_agent_mcp/_vendored/connector_sdk/executor/hosted_executor.py +188 -0
- airbyte_agent_mcp/_vendored/connector_sdk/executor/local_executor.py +1504 -0
- airbyte_agent_mcp/_vendored/connector_sdk/executor/models.py +190 -0
- airbyte_agent_mcp/_vendored/connector_sdk/extensions.py +655 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/__init__.py +37 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/config.py +98 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/exceptions.py +119 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/protocols.py +114 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http/response.py +102 -0
- airbyte_agent_mcp/_vendored/connector_sdk/http_client.py +679 -0
- airbyte_agent_mcp/_vendored/connector_sdk/logging/__init__.py +11 -0
- airbyte_agent_mcp/_vendored/connector_sdk/logging/logger.py +264 -0
- airbyte_agent_mcp/_vendored/connector_sdk/logging/types.py +92 -0
- airbyte_agent_mcp/_vendored/connector_sdk/observability/__init__.py +11 -0
- airbyte_agent_mcp/_vendored/connector_sdk/observability/models.py +19 -0
- airbyte_agent_mcp/_vendored/connector_sdk/observability/redactor.py +81 -0
- airbyte_agent_mcp/_vendored/connector_sdk/observability/session.py +94 -0
- airbyte_agent_mcp/_vendored/connector_sdk/performance/__init__.py +6 -0
- airbyte_agent_mcp/_vendored/connector_sdk/performance/instrumentation.py +57 -0
- airbyte_agent_mcp/_vendored/connector_sdk/performance/metrics.py +93 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/__init__.py +75 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/base.py +160 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/components.py +238 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/connector.py +131 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/extensions.py +109 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/operations.py +146 -0
- airbyte_agent_mcp/_vendored/connector_sdk/schema/security.py +213 -0
- airbyte_agent_mcp/_vendored/connector_sdk/secrets.py +182 -0
- airbyte_agent_mcp/_vendored/connector_sdk/telemetry/__init__.py +10 -0
- airbyte_agent_mcp/_vendored/connector_sdk/telemetry/config.py +32 -0
- airbyte_agent_mcp/_vendored/connector_sdk/telemetry/events.py +58 -0
- airbyte_agent_mcp/_vendored/connector_sdk/telemetry/tracker.py +151 -0
- airbyte_agent_mcp/_vendored/connector_sdk/types.py +239 -0
- airbyte_agent_mcp/_vendored/connector_sdk/utils.py +60 -0
- airbyte_agent_mcp/_vendored/connector_sdk/validation.py +822 -0
- airbyte_agent_mcp/config.py +97 -0
- airbyte_agent_mcp/connector_manager.py +340 -0
- airbyte_agent_mcp/models.py +147 -0
- airbyte_agent_mcp/registry_client.py +103 -0
- airbyte_agent_mcp/secret_manager.py +94 -0
- airbyte_agent_mcp/server.py +265 -0
- airbyte_agent_mcp-0.1.30.dist-info/METADATA +134 -0
- airbyte_agent_mcp-0.1.30.dist-info/RECORD +56 -0
- 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
|