Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
107b4ab
feat(mcp): classify tool errors on native Datadog spans
cjkenned Oct 9, 2026
d3a8a25
fix(mcp): add error facts to failed Datadog tool-call spans
calebevans-ab Oct 9, 2026
7f0ec1a
feat(mcp): merge Datadog error classification onto the prerelease lib…
calebevans-ab Oct 9, 2026
1689608
feat(mcp): report the API problem slug as error.reason on both tracin…
calebevans-ab Oct 9, 2026
f3a5770
fix(mcp): never export an ID-shaped problem slug as the error reason
calebevans-ab Oct 9, 2026
e89f639
fix(mcp): classify invalid arguments and unknown tools on Datadog spa…
calebevans-ab Oct 9, 2026
bfceadb
Apply suggestion from @aaronsteers
aaronsteers Oct 9, 2026
d74eeb3
feat(mcp): export a frames-only error stack on Datadog tool spans
calebevans-ab Oct 10, 2026
82d5db0
chore(ci): suppress intentional release pin TODO
calebevans-ab Oct 10, 2026
a0f60ed
Revert "chore(ci): suppress intentional release pin TODO"
calebevans-ab Oct 10, 2026
a7cd0d3
feat(mcp): give agents actionable Cloud API errors and mask echoed va…
calebevans-ab Oct 10, 2026
a16d535
fix(mcp): match Cloud problem types in every published format
calebevans-ab Oct 10, 2026
c6ae9d2
fix(mcp): mask echoed values as whole tokens and read problem titles …
calebevans-ab Oct 10, 2026
bf6340b
refactor(mcp): move Cloud error messages into a YAML data file
calebevans-ab Oct 10, 2026
c5535d8
fix(mcp): show only known Cloud error types in agent messages
calebevans-ab Oct 10, 2026
bfc33b5
docs(mcp): document how to maintain Cloud error messages
calebevans-ab Oct 10, 2026
b20f1c5
fix(mcp): align agent error handling and API boundary
calebevans-ab Oct 10, 2026
0831292
chore(deps): repin fastmcp-extensions prerelease
calebevans-ab Oct 10, 2026
132b7db
fix(mcp): tag raised tool-call errors
calebevans-ab Oct 10, 2026
1ab4f5b
chore(deps): pin fastmcp-extensions>=0.34.1
calebevans-ab Oct 10, 2026
1fc2d5f
fix(mcp): close remaining agent-text and telemetry gaps from review
calebevans-ab Oct 10, 2026
1738378
chore(deps): pin fastmcp-extensions>=0.34.2
calebevans-ab Oct 10, 2026
1a7ea03
fix(mcp): harden error telemetry and validation paths
calebevans-ab Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ Key conventions:
- Make any capped agent preview explicit, because the agent cannot see the user-facing UI.
- Verify the server-side payload contract.
- Capture human-reviewable evidence with MCPJam or Goose Desktop when retesting is requested, including the rendered widget and any important UI interaction.

## Agent-facing Cloud Error Messages

Cloud API error text shown to agents comes from `airbyte/_util/cloud_errors.yaml`. Read its
header and the matching section in `CONTRIBUTING.md` before you change it.
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ As a general rule (with rare exceptions), there shouldn't be anything that you c
the MCP tools which you couldn't also do with the public Python interface. (Hence the framing
as a "presentation" layer on top of the core modules.)

## Agent-facing Cloud Error Messages

When an Airbyte Cloud API call fails, the text the agent sees comes from the fixed table in
`airbyte/_util/cloud_errors.yaml`, keyed by Cloud's problem type. Never build that text from
Cloud's `detail`, `data.message` or other response fields, because they can quote secrets or
server internals. To support a new problem type, add a `message` and a `guidance` entry
to the YAML file and add the type to `test_every_published_problem_type_matches_a_fixed_message`.
The header of the YAML file lists the remaining rules.

## 🚀 Releasing

This project uses [`semantic-pr-release-drafter`](https://github.com/airbytehq/semantic-pr-release-drafter) for automated release management. To release, simply click "`Edit`" on the latest release draft from the [releases page](https://github.com/airbytehq/PyAirbyte/releases), and then click "`Publish release`". This publish operation will trigger all necessary downstream publish operations.
Expand Down
20 changes: 16 additions & 4 deletions airbyte/_connector_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

if TYPE_CHECKING:
import logging
from collections.abc import Generator
from collections.abc import Generator, Iterable
from typing import IO

from airbyte._executors.base import Executor
Expand All @@ -52,6 +52,16 @@
MAX_LOG_LINES = 20


def _json_path_from_absolute_path(path: Iterable[object]) -> str:
json_path = "$"
for part in path:
if isinstance(part, str):
json_path += f".{part}"
elif isinstance(part, int):
json_path += f"[{part}]"
return json_path


class ConnectorBase(abc.ABC):
"""A class representing a destination that can be called."""

Expand Down Expand Up @@ -185,11 +195,13 @@ def validate_config(self, config: dict[str, Any] | None = None) -> None:
except jsonschema.ValidationError as ex:
validation_ex = exc.AirbyteConnectorValidationFailedError(
connector_name=self.name,
message="The provided config is not valid.",
message=(
f"The provided config is not valid at "
f"`{_json_path_from_absolute_path(ex.absolute_path)}` "
f"(failed the `{ex.validator}` rule)."
),
context={
"error_message": ex.message,
"error_path": ex.path,
"error_instance": ex.instance,
"error_schema": ex.schema,
},
)
Expand Down
109 changes: 70 additions & 39 deletions airbyte/_util/api_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
from airbyte_api import api, models
from airbyte_api.errors import SDKError

from airbyte._util.cloud_errors import describe_cloud_error, parse_cloud_error
from airbyte._util.meta import (
AIRBYTE_CLOUD_ANALYTIC_SOURCE_HEADER,
get_cloud_api_analytic_source,
is_mcp_mode,
)
from airbyte.constants import CLOUD_API_ROOT, CLOUD_CONFIG_API_ROOT, CLOUD_CONFIG_API_ROOT_ENV_VAR
from airbyte.exceptions import (
AirbyteCloudApiError,
AirbyteCloudError,
AirbyteConnectionSyncActiveError,
AirbyteConnectionSyncError,
Expand Down Expand Up @@ -160,6 +162,23 @@ def _get_sdk_error_context(error: SDKError) -> dict[str, Any]:
return context


def error_response_body(error: BaseException) -> str | None:
"""Return the raw response body an Airbyte API error carries, if any."""
if isinstance(error, SDKError):
return error.body
if isinstance(error, AirbyteCloudApiError):
body = (error.context or {}).get("response_text")
return body if isinstance(body, str) else None
return None


def sdk_error_response(error: BaseException) -> tuple[int | None, str | None] | None:
"""Return the status code and body carried by an SDKError."""
if isinstance(error, SDKError):
return error.status_code, error.body
return None


def _is_actor_not_ready_error(error: SDKError) -> bool:
"""Check whether an SDKError contains Airbyte Cloud's actor-not-ready problem."""
if error.status_code != HTTPStatus.CONFLICT:
Expand Down Expand Up @@ -187,7 +206,12 @@ def _wrap_sdk_error(
full URL context, making it easier to debug API issues like 403 and 404 errors.
"""
sdk_context = _get_sdk_error_context(error)
merged_context = {**(base_context or {}), **sdk_context}
problem = parse_cloud_error(error.status_code, error.body)
merged_context = {
**(base_context or {}),
**sdk_context,
"problem_type": problem.slug,
}
if _is_actor_not_ready_error(error):
return AirbyteConnectorNotReadyError(
message=ACTOR_NOT_READY_ERROR_MESSAGE,
Expand All @@ -199,6 +223,7 @@ def _wrap_sdk_error(

status_code = sdk_context.get("status_code")
is_forbidden = status_code == HTTPStatus.FORBIDDEN
message, guidance = describe_cloud_error(problem)
error_type = (
AirbyteMissingResourceError
if is_forbidden or status_code == HTTPStatus.NOT_FOUND
Expand All @@ -209,26 +234,13 @@ def _wrap_sdk_error(
"The requested resource was not found, or these credentials can't access it "
"(HTTP 403)."
if is_forbidden
else f"API error occurred: {error.message}"
else message
),
guidance=FORBIDDEN_RESOURCE_GUIDANCE if is_forbidden else None,
guidance=FORBIDDEN_RESOURCE_GUIDANCE if is_forbidden else guidance,
context=merged_context,
)


def _get_sdk_error_detail(error: SDKError) -> str | None:
"""Return the human-readable message from an Airbyte API error body, if present."""
try:
body = json.loads(error.body)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(body, dict):
return None
data = body.get("data")
candidates = (data.get("message") if isinstance(data, dict) else None, body.get("detail"))
return next((c for c in candidates if isinstance(c, str) and c), None)


def _infer_config_api_root(api_root: str) -> str | None:
"""Infer the configuration API root from a public API root."""
normalized_api_root = api_root.rstrip("/")
Expand Down Expand Up @@ -1718,12 +1730,20 @@ def get_source_definition(
bearer_token=bearer_token,
api_root=api_root,
)
response = airbyte_instance.source_definitions.get_source_definition(
api.GetSourceDefinitionRequest(
definition_id=definition_id,
workspace_id=workspace_id,
),
)
base_context = {
"workspace_id": workspace_id,
"definition_id": definition_id,
"api_root": api_root,
}
try:
response = airbyte_instance.source_definitions.get_source_definition(
api.GetSourceDefinitionRequest(
definition_id=definition_id,
workspace_id=workspace_id,
),
)
except SDKError as e:
raise _wrap_sdk_error(e, base_context) from e
if status_ok(response.status_code) and response.definition_response:
return response.definition_response

Expand Down Expand Up @@ -1754,12 +1774,20 @@ def get_destination_definition(
bearer_token=bearer_token,
api_root=api_root,
)
response = airbyte_instance.destination_definitions.get_destination_definition(
api.GetDestinationDefinitionRequest(
definition_id=definition_id,
workspace_id=workspace_id,
),
)
base_context = {
"workspace_id": workspace_id,
"definition_id": definition_id,
"api_root": api_root,
}
try:
response = airbyte_instance.destination_definitions.get_destination_definition(
api.GetDestinationDefinitionRequest(
definition_id=definition_id,
workspace_id=workspace_id,
),
)
except SDKError as e:
raise _wrap_sdk_error(e, base_context) from e
if status_ok(response.status_code) and response.definition_response:
return response.definition_response

Expand Down Expand Up @@ -1981,9 +2009,9 @@ def create_connection( # noqa: PLR0913 # Too many arguments
"selected_stream_names": selected_stream_names,
}
if e.status_code == HTTPStatus.BAD_REQUEST:
wrapped_message, _ = describe_cloud_error(parse_cloud_error(e.status_code, e.body))
raise AirbyteLibInputError(
message=_get_sdk_error_detail(e)
or "The Airbyte API rejected the connection request (HTTP 400).",
message=wrapped_message,
guidance=(
"Check that every name in `selected_streams` is a stream of the "
"source, and that `source_id` and `destination_id` are correct."
Expand Down Expand Up @@ -2330,15 +2358,15 @@ def _make_config_api_request(
try:
response.raise_for_status()
except requests.HTTPError as ex:
error_message = f"API request failed with status {response.status_code}"
problem = parse_cloud_error(response.status_code, response.text)
message, guidance = describe_cloud_error(problem)
error_context = {
"full_url": full_url,
"config_api_root": config_api_root,
"path": path,
"status_code": response.status_code,
"url": response.request.url,
"body": response.request.body,
"response": response.__dict__,
"problem_type": problem.slug,
}
if response.status_code == HTTPStatus.FORBIDDEN:
raise AirbyteMissingResourceError(
Expand All @@ -2350,7 +2378,8 @@ def _make_config_api_request(
context=error_context,
) from ex
raise AirbyteCloudError(
message=error_message,
message=message,
guidance=guidance,
context=error_context,
) from ex

Expand Down Expand Up @@ -2416,18 +2445,19 @@ def _get_public_api_json(
try:
response.raise_for_status()
except requests.HTTPError as ex:
error_message = f"API request failed with status {response.status_code}"
problem = parse_cloud_error(response.status_code, response.text)
message, guidance = describe_cloud_error(problem)
error_context = {
"full_url": full_url,
"path": path,
"status_code": response.status_code,
"url": response.request.url,
"body": response.request.body,
"response": response.__dict__,
"problem_type": problem.slug,
}
if response.status_code == HTTPStatus.NOT_FOUND:
raise AirbyteMissingResourceError(
message=error_message,
message=message,
guidance=guidance,
context=error_context,
) from ex
if response.status_code == HTTPStatus.FORBIDDEN:
Expand All @@ -2440,7 +2470,8 @@ def _get_public_api_json(
context=error_context,
) from ex
raise AirbyteCloudError(
message=error_message,
message=message,
guidance=guidance,
context=error_context,
) from ex

Expand Down
Loading
Loading