airbyte-agent-mcp 0.1.30__py3-none-any.whl → 0.1.53__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 (22) hide show
  1. airbyte_agent_mcp/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
  2. airbyte_agent_mcp/_vendored/connector_sdk/cloud_utils/client.py +213 -0
  3. airbyte_agent_mcp/_vendored/connector_sdk/connector_model_loader.py +31 -4
  4. airbyte_agent_mcp/_vendored/connector_sdk/constants.py +1 -1
  5. airbyte_agent_mcp/_vendored/connector_sdk/executor/hosted_executor.py +93 -84
  6. airbyte_agent_mcp/_vendored/connector_sdk/executor/local_executor.py +93 -23
  7. airbyte_agent_mcp/_vendored/connector_sdk/extensions.py +42 -3
  8. airbyte_agent_mcp/_vendored/connector_sdk/http_client.py +50 -43
  9. airbyte_agent_mcp/_vendored/connector_sdk/introspection.py +262 -0
  10. airbyte_agent_mcp/_vendored/connector_sdk/observability/config.py +179 -0
  11. airbyte_agent_mcp/_vendored/connector_sdk/observability/session.py +35 -28
  12. airbyte_agent_mcp/_vendored/connector_sdk/schema/base.py +2 -1
  13. airbyte_agent_mcp/_vendored/connector_sdk/schema/components.py +2 -1
  14. airbyte_agent_mcp/_vendored/connector_sdk/schema/operations.py +1 -1
  15. airbyte_agent_mcp/_vendored/connector_sdk/schema/security.py +10 -0
  16. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/events.py +2 -1
  17. airbyte_agent_mcp/_vendored/connector_sdk/telemetry/tracker.py +3 -0
  18. airbyte_agent_mcp/_vendored/connector_sdk/types.py +7 -1
  19. airbyte_agent_mcp/config.py +1 -1
  20. {airbyte_agent_mcp-0.1.30.dist-info → airbyte_agent_mcp-0.1.53.dist-info}/METADATA +1 -1
  21. {airbyte_agent_mcp-0.1.30.dist-info → airbyte_agent_mcp-0.1.53.dist-info}/RECORD +22 -18
  22. {airbyte_agent_mcp-0.1.30.dist-info → airbyte_agent_mcp-0.1.53.dist-info}/WHEEL +0 -0
@@ -674,16 +674,16 @@ class LocalExecutor:
674
674
  return {key: value for key, value in params.items() if key in allowed_params}
675
675
 
676
676
  def _extract_body(self, allowed_fields: list[str], params: dict[str, Any]) -> dict[str, Any]:
677
- """Extract body fields from params.
677
+ """Extract body fields from params, filtering out None values.
678
678
 
679
679
  Args:
680
680
  allowed_fields: List of allowed body field names
681
681
  params: All parameters
682
682
 
683
683
  Returns:
684
- Dictionary of body fields
684
+ Dictionary of body fields with None values filtered out
685
685
  """
686
- return {key: value for key, value in params.items() if key in allowed_fields}
686
+ return {key: value for key, value in params.items() if key in allowed_fields and value is not None}
687
687
 
688
688
  def _serialize_deep_object_params(self, params: dict[str, Any], deep_object_param_names: list[str]) -> dict[str, Any]:
689
689
  """Serialize deepObject parameters to bracket notation format.
@@ -837,15 +837,65 @@ class LocalExecutor:
837
837
  Request body dict or None if no body needed
838
838
  """
839
839
  if endpoint.graphql_body:
840
- return self._build_graphql_body(endpoint.graphql_body, params)
840
+ # Extract defaults from query_params_schema for GraphQL variable interpolation
841
+ param_defaults = {name: schema.get("default") for name, schema in endpoint.query_params_schema.items() if "default" in schema}
842
+ return self._build_graphql_body(endpoint.graphql_body, params, param_defaults)
841
843
  elif endpoint.body_fields:
842
844
  return self._extract_body(endpoint.body_fields, params)
843
845
  return None
844
846
 
847
+ def _flatten_form_data(self, data: dict[str, Any], parent_key: str = "") -> dict[str, Any]:
848
+ """Flatten nested dict/list structures into bracket notation for form encoding.
849
+
850
+ Stripe and similar APIs require nested arrays/objects to be encoded using bracket
851
+ notation when using application/x-www-form-urlencoded content type.
852
+
853
+ Args:
854
+ data: Nested dict with arrays/objects to flatten
855
+ parent_key: Parent key for nested structures (used in recursion)
856
+
857
+ Returns:
858
+ Flattened dict with bracket notation keys
859
+
860
+ Examples:
861
+ >>> _flatten_form_data({"items": [{"price": "p1", "qty": 1}]})
862
+ {"items[0][price]": "p1", "items[0][qty]": 1}
863
+
864
+ >>> _flatten_form_data({"customer": "cus_123", "metadata": {"key": "value"}})
865
+ {"customer": "cus_123", "metadata[key]": "value"}
866
+ """
867
+ flattened = {}
868
+
869
+ for key, value in data.items():
870
+ new_key = f"{parent_key}[{key}]" if parent_key else key
871
+
872
+ if isinstance(value, dict):
873
+ # Recursively flatten nested dicts
874
+ flattened.update(self._flatten_form_data(value, new_key))
875
+ elif isinstance(value, list):
876
+ # Flatten arrays with indexed bracket notation
877
+ for i, item in enumerate(value):
878
+ indexed_key = f"{new_key}[{i}]"
879
+ if isinstance(item, dict):
880
+ # Nested dict in array - recurse
881
+ flattened.update(self._flatten_form_data(item, indexed_key))
882
+ elif isinstance(item, list):
883
+ # Nested list in array - recurse
884
+ flattened.update(self._flatten_form_data({str(i): item}, new_key))
885
+ else:
886
+ # Primitive value in array
887
+ flattened[indexed_key] = item
888
+ else:
889
+ # Primitive value - add directly
890
+ flattened[new_key] = value
891
+
892
+ return flattened
893
+
845
894
  def _determine_request_format(self, endpoint: EndpointDefinition, body: dict[str, Any] | None) -> dict[str, Any]:
846
895
  """Determine json/data parameters for HTTP request.
847
896
 
848
897
  GraphQL always uses JSON, regardless of content_type setting.
898
+ For form-encoded requests, nested structures are flattened into bracket notation.
849
899
 
850
900
  Args:
851
901
  endpoint: Endpoint definition
@@ -862,7 +912,9 @@ class LocalExecutor:
862
912
  if is_graphql or endpoint.content_type.value == "application/json":
863
913
  return {"json": body}
864
914
  elif endpoint.content_type.value == "application/x-www-form-urlencoded":
865
- return {"data": body}
915
+ # Flatten nested structures for form encoding
916
+ flattened_body = self._flatten_form_data(body)
917
+ return {"data": flattened_body}
866
918
 
867
919
  return {}
868
920
 
@@ -903,12 +955,18 @@ class LocalExecutor:
903
955
 
904
956
  return query
905
957
 
906
- def _build_graphql_body(self, graphql_config: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
958
+ def _build_graphql_body(
959
+ self,
960
+ graphql_config: dict[str, Any],
961
+ params: dict[str, Any],
962
+ param_defaults: dict[str, Any] | None = None,
963
+ ) -> dict[str, Any]:
907
964
  """Build GraphQL request body with variable substitution and field selection.
908
965
 
909
966
  Args:
910
967
  graphql_config: GraphQL configuration from x-airbyte-body-type extension
911
968
  params: Parameters from execute() call
969
+ param_defaults: Default values for params from query_params_schema
912
970
 
913
971
  Returns:
914
972
  GraphQL request body: {"query": "...", "variables": {...}}
@@ -922,7 +980,7 @@ class LocalExecutor:
922
980
 
923
981
  # Substitute variables from params
924
982
  if "variables" in graphql_config and graphql_config["variables"]:
925
- body["variables"] = self._interpolate_variables(graphql_config["variables"], params)
983
+ body["variables"] = self._interpolate_variables(graphql_config["variables"], params, param_defaults)
926
984
 
927
985
  # Add operation name if specified
928
986
  if "operationName" in graphql_config:
@@ -981,7 +1039,12 @@ class LocalExecutor:
981
1039
  fields_str = " ".join(graphql_fields)
982
1040
  return query.replace("{{ fields }}", fields_str)
983
1041
 
984
- def _interpolate_variables(self, variables: dict[str, Any], params: dict[str, Any]) -> dict[str, Any]:
1042
+ def _interpolate_variables(
1043
+ self,
1044
+ variables: dict[str, Any],
1045
+ params: dict[str, Any],
1046
+ param_defaults: dict[str, Any] | None = None,
1047
+ ) -> dict[str, Any]:
985
1048
  """Recursively interpolate variables using params.
986
1049
 
987
1050
  Preserves types (doesn't stringify everything).
@@ -990,15 +1053,18 @@ class LocalExecutor:
990
1053
  - Direct replacement: "{{ owner }}" → params["owner"] (preserves type)
991
1054
  - Nested objects: {"input": {"name": "{{ name }}"}}
992
1055
  - Arrays: [{"id": "{{ id }}"}]
993
- - Unsubstituted placeholders: "{{ states }}" → None (for optional params)
1056
+ - Default values: "{{ per_page }}" → param_defaults["per_page"] if not in params
1057
+ - Unsubstituted placeholders: "{{ states }}" → None (for optional params without defaults)
994
1058
 
995
1059
  Args:
996
1060
  variables: Variables dict with template placeholders
997
1061
  params: Parameters to substitute
1062
+ param_defaults: Default values for params from query_params_schema
998
1063
 
999
1064
  Returns:
1000
1065
  Interpolated variables dict with types preserved
1001
1066
  """
1067
+ defaults = param_defaults or {}
1002
1068
 
1003
1069
  def interpolate_value(value: Any) -> Any:
1004
1070
  if isinstance(value, str):
@@ -1012,8 +1078,15 @@ class LocalExecutor:
1012
1078
  value = value.replace(placeholder, str(param_value))
1013
1079
 
1014
1080
  # Check if any unsubstituted placeholders remain
1015
- # If so, return None (treats as "not provided" for optional params)
1016
1081
  if re.search(r"\{\{\s*\w+\s*\}\}", value):
1082
+ # Extract placeholder name and check for default value
1083
+ match = re.search(r"\{\{\s*(\w+)\s*\}\}", value)
1084
+ if match:
1085
+ param_name = match.group(1)
1086
+ if param_name in defaults:
1087
+ # Use default value (preserves type)
1088
+ return defaults[param_name]
1089
+ # No default found - return None (for optional params)
1017
1090
  return None
1018
1091
 
1019
1092
  return value
@@ -1056,7 +1129,7 @@ class LocalExecutor:
1056
1129
  if not action:
1057
1130
  return response_data
1058
1131
 
1059
- is_array_action = action in (Action.LIST, Action.SEARCH)
1132
+ is_array_action = action in (Action.LIST, Action.API_SEARCH)
1060
1133
 
1061
1134
  try:
1062
1135
  # Parse and apply JSONPath expression
@@ -1151,21 +1224,18 @@ class LocalExecutor:
1151
1224
  if action not in (Action.CREATE, Action.UPDATE):
1152
1225
  return
1153
1226
 
1154
- # Check if endpoint has body fields defined
1155
- if not endpoint.body_fields:
1227
+ # Get the request schema to find truly required fields
1228
+ request_schema = endpoint.request_schema
1229
+ if not request_schema:
1156
1230
  return
1157
1231
 
1158
- # For now, we treat all body_fields as potentially required for CREATE/UPDATE
1159
- # In a more advanced implementation, we could parse the request schema
1160
- # to identify truly required fields
1161
- missing_fields = []
1162
- for field in endpoint.body_fields:
1163
- if field not in params:
1164
- missing_fields.append(field)
1232
+ # Only validate fields explicitly marked as required in the schema
1233
+ required_fields = request_schema.get("required", [])
1234
+ missing_fields = [field for field in required_fields if field not in params]
1165
1235
 
1166
1236
  if missing_fields:
1167
1237
  raise MissingParameterError(
1168
- f"Missing required body fields for {entity}.{action.value}: {missing_fields}. Provided parameters: {list(params.keys())}"
1238
+ f"Missing required body fields for {entity}.{action.value}: {missing_fields}. " f"Provided parameters: {list(params.keys())}"
1169
1239
  )
1170
1240
 
1171
1241
  async def close(self):
@@ -1189,7 +1259,7 @@ class LocalExecutor:
1189
1259
 
1190
1260
 
1191
1261
  class _StandardOperationHandler:
1192
- """Handler for standard REST operations (GET, LIST, CREATE, UPDATE, DELETE, SEARCH, AUTHORIZE)."""
1262
+ """Handler for standard REST operations (GET, LIST, CREATE, UPDATE, DELETE, API_SEARCH, AUTHORIZE)."""
1193
1263
 
1194
1264
  def __init__(self, context: _OperationContext):
1195
1265
  self.ctx = context
@@ -1202,7 +1272,7 @@ class _StandardOperationHandler:
1202
1272
  Action.CREATE,
1203
1273
  Action.UPDATE,
1204
1274
  Action.DELETE,
1205
- Action.SEARCH,
1275
+ Action.API_SEARCH,
1206
1276
  Action.AUTHORIZE,
1207
1277
  }
1208
1278
 
@@ -159,6 +159,38 @@ Example:
159
159
  ```
160
160
  """
161
161
 
162
+ AIRBYTE_STREAM_NAME = "x-airbyte-stream-name"
163
+ """
164
+ Extension: x-airbyte-stream-name
165
+ Location: Schema object (in components.schemas)
166
+ Type: string
167
+ Required: No
168
+
169
+ Description:
170
+ Specifies the Airbyte stream name for cache lookup purposes. This maps the entity
171
+ to the corresponding Airbyte stream, enabling cache-based data retrieval. When
172
+ specified, the EntityDefinition.stream_name field will be populated with this value.
173
+
174
+ This extension is placed on Schema objects alongside x-airbyte-entity-name, following
175
+ the same pattern. The stream name is an entity-level property (not operation-level)
176
+ since an entity maps to exactly one Airbyte stream.
177
+
178
+ Example:
179
+ ```yaml
180
+ components:
181
+ schemas:
182
+ Customer:
183
+ type: object
184
+ x-airbyte-entity-name: customers
185
+ x-airbyte-stream-name: customers
186
+ properties:
187
+ id:
188
+ type: string
189
+ name:
190
+ type: string
191
+ ```
192
+ """
193
+
162
194
  AIRBYTE_TOKEN_PATH = "x-airbyte-token-path"
163
195
  """
164
196
  Extension: x-airbyte-token-path
@@ -495,8 +527,8 @@ class ActionType(str, Enum):
495
527
  DELETE = "delete"
496
528
  """Delete a record"""
497
529
 
498
- SEARCH = "search"
499
- """Search for records matching specific query criteria"""
530
+ API_SEARCH = "api_search"
531
+ """Search for records matching specific query criteria via API"""
500
532
 
501
533
  DOWNLOAD = "download"
502
534
  """Download file content from a URL specified in the metadata response"""
@@ -514,7 +546,7 @@ class BodyType(str, Enum):
514
546
 
515
547
 
516
548
  # Type alias for use in Pydantic models
517
- ActionTypeLiteral = Literal["get", "list", "create", "update", "delete", "search", "download"]
549
+ ActionTypeLiteral = Literal["get", "list", "create", "update", "delete", "api_search", "download"]
518
550
 
519
551
 
520
552
  # =============================================================================
@@ -548,6 +580,7 @@ def get_all_extension_names() -> list[str]:
548
580
  AIRBYTE_ENTITY,
549
581
  AIRBYTE_ACTION,
550
582
  AIRBYTE_ENTITY_NAME,
583
+ AIRBYTE_STREAM_NAME,
551
584
  AIRBYTE_TOKEN_PATH,
552
585
  AIRBYTE_BODY_TYPE,
553
586
  AIRBYTE_PATH_OVERRIDE,
@@ -594,6 +627,12 @@ EXTENSION_REGISTRY = {
594
627
  "required": False,
595
628
  "description": "Links schema to an entity/stream",
596
629
  },
630
+ AIRBYTE_STREAM_NAME: {
631
+ "location": "schema",
632
+ "type": "string",
633
+ "required": False,
634
+ "description": "Maps entity to Airbyte stream for cache lookup",
635
+ },
597
636
  AIRBYTE_TOKEN_PATH: {
598
637
  "location": "securityScheme",
599
638
  "type": "string",
@@ -147,6 +147,9 @@ class HTTPClient:
147
147
  self.base_url = self.base_url.replace(f"{{{var_name}}}", var_value)
148
148
 
149
149
  self.auth_config = auth_config
150
+ assert (
151
+ self.auth_config.type is not None
152
+ ), "auth_config.type cannot be None" # Should never be None when instantiated via the local executor flow
150
153
  self.secrets = secrets
151
154
  self.logger = logger or NullLogger()
152
155
  self.metrics = HTTPMetrics()
@@ -296,12 +299,12 @@ class HTTPClient:
296
299
 
297
300
  # Support both sync and async callbacks
298
301
  callback_result = self.on_token_refresh(callback_data)
299
- if hasattr(callback_result, "__await__"):
302
+ if callback_result is not None and hasattr(callback_result, "__await__"):
300
303
  await callback_result
301
304
  except Exception as callback_error:
302
305
  self.logger.log_error(
303
306
  request_id=None,
304
- error=("Token refresh callback failed during initialization: " f"{callback_error!s}"),
307
+ error=(f"Token refresh callback failed during initialization: {callback_error!s}"),
305
308
  status_code=None,
306
309
  )
307
310
 
@@ -485,7 +488,7 @@ class HTTPClient:
485
488
  elif "application/json" in content_type or not content_type:
486
489
  response_data = await response.json()
487
490
  else:
488
- error_msg = f"Expected JSON response for {method.upper()} {url}, " f"got content-type: {content_type}"
491
+ error_msg = f"Expected JSON response for {method.upper()} {url}, got content-type: {content_type}"
489
492
  raise HTTPClientError(error_msg)
490
493
 
491
494
  except ValueError as e:
@@ -556,6 +559,7 @@ class HTTPClient:
556
559
  current_token = self.secrets.get("access_token")
557
560
  strategy = AuthStrategyFactory.get_strategy(self.auth_config.type)
558
561
 
562
+ # Try to refresh credentials
559
563
  try:
560
564
  result = await strategy.handle_auth_error(
561
565
  status_code=status_code,
@@ -564,53 +568,56 @@ class HTTPClient:
564
568
  config_values=self.config_values,
565
569
  http_client=None, # Let strategy create its own client
566
570
  )
567
-
568
- if result:
569
- # Notify callback if provided (for persistence)
570
- # Include both tokens AND extracted values for full persistence
571
- if self.on_token_refresh is not None:
572
- try:
573
- # Build callback data with both tokens and extracted values
574
- callback_data = dict(result.tokens)
575
- if result.extracted_values:
576
- callback_data.update(result.extracted_values)
577
-
578
- # Support both sync and async callbacks
579
- callback_result = self.on_token_refresh(callback_data)
580
- if hasattr(callback_result, "__await__"):
581
- await callback_result
582
- except Exception as callback_error:
583
- self.logger.log_error(
584
- request_id=request_id,
585
- error=f"Token refresh callback failed: {str(callback_error)}",
586
- status_code=status_code,
587
- )
588
-
589
- # Update secrets with new tokens (in-memory)
590
- self.secrets.update(result.tokens)
591
-
592
- # Update config_values and re-render base_url with extracted values
593
- if result.extracted_values:
594
- self._apply_token_extract(result.extracted_values)
595
-
596
- if self.secrets.get("access_token") != current_token:
597
- # Retry with new token - this will go through full retry logic
598
- return await self.request(
599
- method=method,
600
- path=path,
601
- params=params,
602
- json=json,
603
- data=data,
604
- headers=headers,
605
- )
606
-
607
571
  except Exception as refresh_error:
608
572
  self.logger.log_error(
609
573
  request_id=request_id,
610
574
  error=f"Credential refresh failed: {str(refresh_error)}",
611
575
  status_code=status_code,
612
576
  )
577
+ result = None
578
+
579
+ # If refresh succeeded, update tokens and retry
580
+ if result:
581
+ # Notify callback if provided (for persistence)
582
+ # Include both tokens AND extracted values for full persistence
583
+ if self.on_token_refresh is not None:
584
+ try:
585
+ # Build callback data with both tokens and extracted values
586
+ callback_data = dict(result.tokens)
587
+ if result.extracted_values:
588
+ callback_data.update(result.extracted_values)
589
+
590
+ # Support both sync and async callbacks
591
+ callback_result = self.on_token_refresh(callback_data)
592
+ if callback_result is not None and hasattr(callback_result, "__await__"):
593
+ await callback_result
594
+ except Exception as callback_error:
595
+ self.logger.log_error(
596
+ request_id=request_id,
597
+ error=f"Token refresh callback failed: {str(callback_error)}",
598
+ status_code=status_code,
599
+ )
600
+
601
+ # Update secrets with new tokens (in-memory)
602
+ self.secrets.update(result.tokens)
603
+
604
+ # Update config_values and re-render base_url with extracted values
605
+ if result.extracted_values:
606
+ self._apply_token_extract(result.extracted_values)
613
607
 
608
+ if self.secrets.get("access_token") != current_token:
609
+ # Retry with new token - this will go through full retry logic
610
+ # Any errors from this retry will propagate to the caller
611
+ return await self.request(
612
+ method=method,
613
+ path=path,
614
+ params=params,
615
+ json=json,
616
+ data=data,
617
+ headers=headers,
618
+ )
619
+
620
+ # Refresh failed or token didn't change, log and let original error propagate
614
621
  self.logger.log_error(request_id=request_id, error=str(error), status_code=status_code)
615
622
 
616
623
  async def request(