From 107b4ab136b0c567b46965fb55aa9695363f586d Mon Sep 17 00:00:00 2001 From: cjkenned Date: Fri, 9 Oct 2026 13:50:18 -0700 Subject: [PATCH 01/22] feat(mcp): classify tool errors on native Datadog spans The native Datadog backend recorded only the exception class and a fixed "tool resulted in an error" message, so failed tool calls could not be grouped by cause. Prod showed 109 describe_cloud_connection failures that were all upstream 500s, which was only visible by opening child spans. - Reuse fastmcp-extensions' error_attributes so Datadog spans carry the same error.category, error.fault, and upstream.status_code as the OTel backend. - Add error.problem_type: the Airbyte API problem slug from the response body. Only the type (or title, for the generic errors page) is read, and only a short lowercase slug is exported; detail and data are never read. - Build error.message from those bounded tags only. - Classify failed and timed-out sync jobs as upstream faults instead of internal, on both backends. - Wrap SDKError in get_source_definition / get_destination_definition so describe_cloud_connector raises AirbyteMissingResourceError instead of a raw SDKError. --- airbyte/_util/api_util.py | 51 +++-- airbyte/mcp/_datadog.py | 100 ++++++++-- airbyte/mcp/_error_handling.py | 18 +- airbyte/mcp/_otel.py | 2 + tests/unit_tests/test_cloud_api_util.py | 33 ++++ tests/unit_tests/test_mcp_datadog.py | 241 ++++++++++++++++++++++- tests/unit_tests/test_mcp_otel.py | 2 + tests/unit_tests/test_mcp_tool_errors.py | 33 ++++ 8 files changed, 454 insertions(+), 26 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 376114a3f..360455e3e 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -33,6 +33,7 @@ ) 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, @@ -160,6 +161,16 @@ 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 _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: @@ -1718,12 +1729,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 @@ -1754,12 +1773,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 diff --git a/airbyte/mcp/_datadog.py b/airbyte/mcp/_datadog.py index a82ddd71a..5680b455f 100644 --- a/airbyte/mcp/_datadog.py +++ b/airbyte/mcp/_datadog.py @@ -17,6 +17,7 @@ from fastmcp.server.middleware import Middleware from fastmcp.telemetry import suppress_fastmcp_telemetry from fastmcp_extensions.otel._arg_digests import ArgTracer, is_arg_key # noqa: PLC2701 +from fastmcp_extensions.otel._extras import _chain, error_attributes # noqa: PLC2701 from fastmcp_extensions.otel.middleware import arg_trace_attributes from mcp.types import ( CallToolResult, @@ -27,6 +28,8 @@ ) from opentelemetry.instrumentation.requests import RequestsInstrumentor +from airbyte._util.api_util import error_response_body +from airbyte.mcp._error_handling import MCP_TOOL_USER_FACING_ERRORS, classify_mcp_tool_error from airbyte.mcp._otel import _arg_key, _env, _flag from airbyte.mcp._scope import current_call_scope, scope_from_request from airbyte.mcp._trace_attributes import agent_action_attributes @@ -70,6 +73,11 @@ _ARG_PREFIX = "airbyte.mcp" # Same classification and validation as the OTel backend; intent is exported separately. _ARG_TRACER = ArgTracer(_ARG_PREFIX, key=_arg_key, skip=(INTENT_ARG,)) +_TOOL_ERROR_MESSAGE = "tool resulted in an error" +_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") +# The last path segment of the generic problem `type` URL; `title` is the slug then. +_GENERIC_PROBLEM_SLUG = "errors" +_MAX_PROBLEM_BODY = 65536 def _build_tool_maps() -> None: @@ -172,16 +180,85 @@ def _request_trace_attributes() -> dict[str, str]: def _exception_attributes(error: BaseException) -> dict[str, str]: - error_type = type(error.__cause__ or error).__name__ + cause = error.__cause__ or error + error_type = type(cause).__name__ return { "airbyte.mcp.outcome": "cancelled" if isinstance(error, asyncio.CancelledError) else "exception", "airbyte.mcp.error_type": error_type, "error.type": error_type, + **_error_classification(cause), } +def _error_classification(cause: BaseException | None) -> dict[str, str]: + """Return the bounded category, fault, upstream status, and problem type of a failure. + + `cause` is `None` for a tool that returned an error result. These are the + OTel backend's `error_attributes`, plus the Airbyte API problem slug. + """ + try: + attrs = { + f"airbyte.mcp.{key}": str(value) + for key, value in error_attributes( + cause, + user_facing_errors=MCP_TOOL_USER_FACING_ERRORS, + classifier=classify_mcp_tool_error, + ).items() + } + problem_type = _problem_type(cause) if cause is not None else None + except Exception: + logger.debug("Datadog error classification unavailable") + return {} + if problem_type is not None: + attrs["airbyte.mcp.error.problem_type"] = problem_type + return attrs + + +def _problem_type(cause: BaseException) -> str | None: + """Return the problem slug from the first Airbyte API error body in the chain. + + Only the problem `type`, or its `title` when `type` is the generic errors + page, is read. `detail` and `data` quote IDs and payloads, so they never are. + """ + body = next( + (body for body in map(error_response_body, _chain(cause)) if body is not None), + None, + ) + if body is None or len(body) > _MAX_PROBLEM_BODY: + return None + try: + problem = json.loads(body) + except (ValueError, RecursionError): + return None + if not isinstance(problem, dict): + return None + for field in ("type", "title"): + value = problem.get(field) + if not isinstance(value, str): + continue + slug = re.split(r"[/#]", value)[-1] + if slug != _GENERIC_PROBLEM_SLUG and _PROBLEM_SLUG.fullmatch(slug): + return slug + return None + + +def _tool_error_message(span: Span) -> str: + """Summarize a tool error from its bounded classification tags only.""" + status = span.get_tag("airbyte.mcp.upstream.status_code") + details = ", ".join( + detail + for detail in ( + span.get_tag("airbyte.mcp.error.category"), + f"HTTP {status}" if status else None, + span.get_tag("airbyte.mcp.error.problem_type"), + ) + if detail + ) + return f"{_TOOL_ERROR_MESSAGE} ({details})" if details else _TOOL_ERROR_MESSAGE + + class _StripMetaTraceContextMiddleware: async def __call__( self, @@ -656,16 +733,17 @@ async def __call__( # noqa: PLR0912, PLR0915 if tool_call and getattr(response, "is_error", False): span.error = 1 error_type = span.get_tag("airbyte.mcp.error_type") or "ToolError" - _annotate_attributes( - span, - { - "airbyte.mcp.outcome": span.get_tag("airbyte.mcp.outcome") - or "tool_error", - "airbyte.mcp.error_type": error_type, - "error.type": error_type, - }, - ) - span.set_tag("error.message", "tool resulted in an error") + error_attrs = { + "airbyte.mcp.outcome": span.get_tag("airbyte.mcp.outcome") + or "tool_error", + "airbyte.mcp.error_type": error_type, + "error.type": error_type, + } + # A returned error result raised nothing to classify. + if span.get_tag("airbyte.mcp.error.category") is None: + error_attrs.update(_error_classification(None)) + _annotate_attributes(span, error_attrs) + span.set_tag("error.message", _tool_error_message(span)) else: _annotate_attributes(span, {"airbyte.mcp.outcome": "success"}) LLMObs.annotate(span, output_data=output) diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index 3549615e9..4fda1bc1c 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -1,10 +1,12 @@ # Copyright (c) 2026 Airbyte, Inc., all rights reserved. -"""PyAirbyte-specific configuration for user-facing MCP errors.""" +"""PyAirbyte-specific handling and trace classification of MCP tool errors.""" from __future__ import annotations from airbyte.exceptions import ( AirbyteAgentsUnavailableError, + AirbyteConnectionSyncError, + AirbyteConnectionSyncTimeoutError, AirbyteConnectorInUseError, AirbyteConnectorNotReadyError, AirbyteExternalAccessDisabledError, @@ -39,3 +41,17 @@ def format_user_facing_error(error: BaseException) -> str: if error.guidance: text = f"{text} {error.guidance}" return text + + +def classify_mcp_tool_error(error: BaseException) -> str | None: + """Return a trace error category the built-in rules cannot infer, else `None`. + + A failed or timed-out sync job carries no HTTP status, so the built-in + rules would report it as an `internal` server fault. Sync errors raised + from an API call have no `job_status` and keep the status-code category. + """ + if not isinstance(error, AirbyteConnectionSyncError) or error.job_status is None: + return None + if isinstance(error, AirbyteConnectionSyncTimeoutError): + return "upstream_timeout" + return "upstream_error" diff --git a/airbyte/mcp/_otel.py b/airbyte/mcp/_otel.py index 81798d9e3..51110ae5b 100644 --- a/airbyte/mcp/_otel.py +++ b/airbyte/mcp/_otel.py @@ -21,6 +21,7 @@ from opentelemetry.trace import SpanKind, Status from airbyte.constants import CLOUD_API_ROOT, CLOUD_CONFIG_API_ROOT +from airbyte.mcp._error_handling import classify_mcp_tool_error if TYPE_CHECKING: @@ -312,6 +313,7 @@ def install(app: FastMCP, *, environ: Mapping[str, str] | None = None) -> None: "auth_method", ), capture_intent=_flag(environ, "AIRBYTE_MCP_INTENT_CAPTURE"), + error_classifier=classify_mcp_tool_error, other_spans=_http_client_span_attributes, arg_key=_arg_key, exporter=_exporter(backend, environ), diff --git a/tests/unit_tests/test_cloud_api_util.py b/tests/unit_tests/test_cloud_api_util.py index d95d4f4fa..cc36eae58 100644 --- a/tests/unit_tests/test_cloud_api_util.py +++ b/tests/unit_tests/test_cloud_api_util.py @@ -1271,6 +1271,39 @@ def test_get_connection_fallback_404_raises_missing_resource_error() -> None: assert exc_info.value.guidance is None +@responses.activate +@pytest.mark.parametrize( + ("lookup", "definitions_path"), + [ + pytest.param(api_util.get_source_definition, "sources", id="source"), + pytest.param( + api_util.get_destination_definition, "destinations", id="destination" + ), + ], +) +def test_get_definition_wraps_sdk_errors( + lookup: Callable[..., models.DefinitionResponse], definitions_path: str +) -> None: + """A missing definition raises the missing-resource error, chained to the SDK error.""" + api_root = "https://api.airbyte.test/api/public/v1" + url = f"{api_root}/workspaces/workspace-id/definitions/{definitions_path}/definition-id" + responses.get(url, status=404, json={"message": "Not found"}) + + with pytest.raises(AirbyteMissingResourceError) as exc_info: + lookup( + definition_id="definition-id", + workspace_id="workspace-id", + api_root=api_root, + client_id=None, + client_secret=None, + bearer_token=SecretString("bearer-token"), + ) + + assert isinstance(exc_info.value.__cause__, SDKError) + assert exc_info.value.context["status_code"] == 404 + assert exc_info.value.context["definition_id"] == "definition-id" + + @responses.activate def test_list_connections_retries_page_without_undecodable_stream_mappers() -> None: """Connection listing falls back to raw JSON when a page has unknown mappers.""" diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 34a0add5b..50ca94841 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -10,8 +10,17 @@ from typing import Any, cast import pytest - -from airbyte._util import meta +import requests +from airbyte_api.errors import SDKError +from fastmcp.exceptions import ToolError + +from airbyte._util import api_util, meta +from airbyte.exceptions import ( + AirbyteCloudApiError, + AirbyteConnectionSyncError, + AirbyteConnectionSyncTimeoutError, + AirbyteSafeModeError, +) from airbyte.mcp import _datadog, _telemetry from airbyte.version import get_version @@ -163,6 +172,228 @@ def test_outbound_mcp_payloads_are_fully_redacted(): assert span.input == span.output == [{"content": "[REDACTED]", "role": ""}] +_GENERIC_PROBLEM_TYPE = "https://reference.airbyte.com/reference/errors" + + +def _wrapped_sdk_error(status_code: int, body: str) -> ToolError: + """Mirror `api_util`'s `raise _wrap_sdk_error(e) from e` inside a FastMCP ToolError.""" + raw_response = requests.Response() + raw_response.status_code = status_code + raw_response.url = "https://api.airbyte.com/v1/connections/SENTINEL-url" + sdk_error = SDKError("API error SENTINEL", status_code, body, raw_response) + wrapped = api_util._wrap_sdk_error(sdk_error, {"connection_id": "SENTINEL-id"}) + wrapped.__cause__ = sdk_error + tool_error = ToolError("tool SENTINEL") + tool_error.__cause__ = wrapped + return tool_error + + +def _problem(problem_type: str, title: str) -> str: + return json.dumps({ + "type": problem_type, + "title": title, + "detail": "SENTINEL detail", + "data": {"message": "ConfigNotFoundException: SENTINEL-workspace"}, + }) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + pytest.param( + _wrapped_sdk_error( + 500, _problem(_GENERIC_PROBLEM_TYPE, "unexpected-problem") + ), + { + "airbyte.mcp.error_type": "AirbyteCloudError", + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": "500", + "airbyte.mcp.error.problem_type": "unexpected-problem", + }, + id="server-error-uses-title-of-generic-problem", + ), + pytest.param( + _wrapped_sdk_error( + 400, + _problem( + "error:connection-conflicting-destination-stream", + "Connection contains conflicting stream(s).", + ), + ), + { + "airbyte.mcp.error_type": "AirbyteCloudError", + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": "400", + "airbyte.mcp.error.problem_type": "error:connection-conflicting-destination-stream", + }, + id="specific-problem-type", + ), + pytest.param( + _wrapped_sdk_error( + 409, + _problem(f"{_GENERIC_PROBLEM_TYPE}#409-actor-not-ready", "SENTINEL"), + ), + { + "airbyte.mcp.error_type": "AirbyteConnectorNotReadyError", + "airbyte.mcp.error.category": "user_error", + "airbyte.mcp.error.fault": "caller", + "airbyte.mcp.upstream.status_code": "409", + "airbyte.mcp.error.problem_type": "409-actor-not-ready", + }, + id="problem-type-fragment", + ), + pytest.param( + _wrapped_sdk_error(404, "SENTINEL not json"), + { + "airbyte.mcp.error_type": "AirbyteMissingResourceError", + "airbyte.mcp.error.category": "user_error", + "airbyte.mcp.error.fault": "caller", + "airbyte.mcp.upstream.status_code": "404", + }, + id="non-json-body-has-no-problem-type", + ), + pytest.param( + _wrapped_sdk_error(500, json.dumps(["SENTINEL"])), + { + "airbyte.mcp.error_type": "AirbyteCloudError", + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": "500", + }, + id="non-object-body-has-no-problem-type", + ), + pytest.param( + _wrapped_sdk_error( + 500, _problem("SENTINEL Free Text", "SENTINEL Free Text") + ), + { + "airbyte.mcp.error_type": "AirbyteCloudError", + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": "500", + }, + id="free-text-problem-is-not-exported", + ), + pytest.param( + _wrapped_sdk_error( + 500, + json.dumps({"type": "too-big", "detail": "SENTINEL" * 10000}), + ), + { + "airbyte.mcp.error_type": "AirbyteCloudError", + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": "500", + }, + id="oversized-body-is-not-parsed", + ), + pytest.param( + AirbyteCloudApiError( + message="SENTINEL message", + status_code=403, + context={"response_text": _problem(_GENERIC_PROBLEM_TYPE, "forbidden")}, + ), + { + "airbyte.mcp.error_type": "AirbyteCloudApiError", + "airbyte.mcp.error.category": "auth", + "airbyte.mcp.error.fault": "caller", + "airbyte.mcp.upstream.status_code": "403", + "airbyte.mcp.error.problem_type": "forbidden", + }, + id="config-api-error-response-text", + ), + pytest.param( + AirbyteSafeModeError(message="SENTINEL message"), + { + "airbyte.mcp.error_type": "AirbyteSafeModeError", + "airbyte.mcp.error.category": "user_error", + "airbyte.mcp.error.fault": "caller", + }, + id="user-facing-error", + ), + pytest.param( + AirbyteConnectionSyncError( + connection_id="SENTINEL", job_id=1, job_status="failed" + ), + { + "airbyte.mcp.error_type": "AirbyteConnectionSyncError", + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + }, + id="failed-sync-job", + ), + pytest.param( + AirbyteConnectionSyncTimeoutError( + connection_id="SENTINEL", job_id=1, job_status="running", timeout=5 + ), + { + "airbyte.mcp.error_type": "AirbyteConnectionSyncTimeoutError", + "airbyte.mcp.error.category": "upstream_timeout", + "airbyte.mcp.error.fault": "upstream", + }, + id="timed-out-sync-job", + ), + pytest.param( + ValueError("SENTINEL message"), + { + "airbyte.mcp.error_type": "ValueError", + "airbyte.mcp.error.category": "internal", + "airbyte.mcp.error.fault": "server", + }, + id="unexpected-error", + ), + ], +) +def test_exception_attributes_classify_without_error_text( + error: BaseException, expected: dict[str, str] +) -> None: + attrs = _datadog._exception_attributes(error) + + assert attrs == { + "airbyte.mcp.outcome": "exception", + "error.type": expected["airbyte.mcp.error_type"], + **expected, + } + assert "SENTINEL" not in json.dumps(attrs) + + +def test_returned_tool_error_is_classified_as_tool_error() -> None: + assert _datadog._error_classification(None) == { + "airbyte.mcp.error.category": "tool_error", + "airbyte.mcp.error.fault": "unknown", + } + + +@pytest.mark.parametrize( + ("tags", "expected"), + [ + pytest.param( + { + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.upstream.status_code": "500", + "airbyte.mcp.error.problem_type": "unexpected-problem", + }, + "tool resulted in an error (upstream_error, HTTP 500, unexpected-problem)", + id="full", + ), + pytest.param( + {"airbyte.mcp.error.category": "user_error"}, + "tool resulted in an error (user_error)", + id="category-only", + ), + pytest.param({}, "tool resulted in an error", id="unclassified"), + ], +) +def test_tool_error_message_uses_classification_tags( + tags: dict[str, str], expected: str +) -> None: + span = cast(Any, SimpleNamespace(get_tag=tags.get)) + + assert _datadog._tool_error_message(span) == expected + + @pytest.mark.parametrize("conflict", ["otel_requests", "native_mcp"]) def test_native_startup_rejects_duplicate_instrumentation(monkeypatch, conflict): import mcp @@ -716,6 +947,7 @@ async def fail(_ctx): ] assert escaped_metadata["outcome"] == "exception" assert escaped_metadata["error_type"] == "ValueError" + assert escaped_metadata["error.category"] == "internal" assert escaped_metadata["auth_method"] == "client_credentials" assert escaped_metadata["mcp_protocol_version"] == "2025-11-25" assert escaped_metadata["session_id"] == client_properties["session_id"] @@ -952,6 +1184,11 @@ async def dispatch(_ctx): assert event["meta"]["metadata"]["outcome"] == outcome assert event["meta"]["metadata"].get("error_type") == error_type assert events[4]["meta"]["metadata"]["error_type"] == "ValueError" + assert events[3]["meta"]["metadata"]["error.category"] == "tool_error" + assert events[4]["meta"]["metadata"]["error.category"] == "internal" + assert primary[3].get_tag("error.message") == ( + "tool resulted in an error (tool_error)" + ) assert events[5]["meta"]["output"]["value"] == "[REDACTED]" assert ( responses[5][2]["result"]["content"][0]["text"] diff --git a/tests/unit_tests/test_mcp_otel.py b/tests/unit_tests/test_mcp_otel.py index 7de70e31a..87b33d9ec 100644 --- a/tests/unit_tests/test_mcp_otel.py +++ b/tests/unit_tests/test_mcp_otel.py @@ -30,6 +30,7 @@ from airbyte._util import meta from airbyte.constants import CLOUD_API_ROOT from airbyte.mcp import _otel as observability +from airbyte.mcp._error_handling import classify_mcp_tool_error from airbyte.mcp import _scope from airbyte.mcp._scope import ( CallScopeMiddleware, @@ -428,6 +429,7 @@ def register(app_arg: object, config: TelemetryConfig) -> None: "auth_method", ) assert tracing.capture_intent is True + assert tracing.error_classifier is classify_mcp_tool_error assert tracing.other_spans is observability._http_client_span_attributes assert tracing.arg_key is observability._arg_key assert tracing.exporter == "otlp" diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index d127e67da..1bff852c6 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -10,6 +10,8 @@ from fastmcp.exceptions import ToolError from airbyte.exceptions import ( + AirbyteConnectionSyncError, + AirbyteConnectionSyncTimeoutError, AirbyteConnectorInUseError, AirbyteMissingResourceError, AirbyteLibError, @@ -17,6 +19,7 @@ ) from airbyte.mcp._error_handling import ( MCP_TOOL_USER_FACING_ERRORS, + classify_mcp_tool_error, format_user_facing_error, ) from fastmcp_extensions import mcp_server @@ -106,3 +109,33 @@ def test_format_user_facing_error() -> None: ) assert format_user_facing_error(AirbyteLibInputError(message="bad")) == "bad" assert format_user_facing_error(ValueError("plain")) == "plain" + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + pytest.param( + AirbyteConnectionSyncError( + connection_id="c", job_id=1, job_status="failed" + ), + "upstream_error", + id="failed-job", + ), + pytest.param( + AirbyteConnectionSyncTimeoutError( + connection_id="c", job_id=1, job_status="running", timeout=5 + ), + "upstream_timeout", + id="timed-out-job", + ), + pytest.param( + AirbyteConnectionSyncError(connection_id="c"), + None, + id="api-error-keeps-status-category", + ), + pytest.param(AirbyteLibInputError(message="bad input"), None, id="other-error"), + pytest.param(ValueError("unexpected"), None, id="non-airbyte-error"), + ], +) +def test_classify_mcp_tool_error(error: BaseException, expected: str | None) -> None: + assert classify_mcp_tool_error(error) == expected From d3a8a250bd6c7c4dc590bc51ab7aa87c325eab8a Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 9 Oct 2026 14:14:39 -0700 Subject: [PATCH 02/22] fix(mcp): add error facts to failed Datadog tool-call spans The native Datadog backend showed only an exception class name on a failed `mcp.tools_call` span. Tag it with the error facts fastmcp-extensions already computes for the `otel` backends and the telemetry event: `airbyte.mcp.error.category`, `airbyte.mcp.error.fault`, `airbyte.mcp.error.cause_types`, and `airbyte.mcp.upstream.status_code`. The values come from the library's shared per-call facts, so the span and the event agree. No exception message is read. Pins fastmcp-extensions to the prerelease built from airbytehq/fastmcp-extensions#148. Co-Authored-By: Claude Opus 5.5 --- airbyte/mcp/_datadog.py | 33 ++++++++++++++- pyproject.toml | 2 +- tests/unit_tests/test_mcp_datadog.py | 60 ++++++++++++++++++++++++++++ uv.lock | 8 ++-- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/airbyte/mcp/_datadog.py b/airbyte/mcp/_datadog.py index a82ddd71a..9e4da0b8c 100644 --- a/airbyte/mcp/_datadog.py +++ b/airbyte/mcp/_datadog.py @@ -16,6 +16,7 @@ from fastmcp.server.middleware import Middleware from fastmcp.telemetry import suppress_fastmcp_telemetry +from fastmcp_extensions._telemetry_middleware import tool_call_facts # noqa: PLC2701 from fastmcp_extensions.otel._arg_digests import ArgTracer, is_arg_key # noqa: PLC2701 from fastmcp_extensions.otel.middleware import arg_trace_attributes from mcp.types import ( @@ -41,6 +42,7 @@ from fastmcp import FastMCP from fastmcp.server.middleware import CallNext, MiddlewareContext from fastmcp.tools import Tool, ToolResult + from fastmcp_extensions._telemetry_middleware import ToolCallFacts from mcp.server.context import ServerRequestContext from mcp.types import CallToolRequestParams @@ -182,6 +184,18 @@ def _exception_attributes(error: BaseException) -> dict[str, str]: } +def _error_fact_attributes( + facts: ToolCallFacts, result: ToolResult | None, error: BaseException | None +) -> dict[str, str]: + """Return the library's error facts for a failed call; no exception message is read.""" + # Settled on the shared facts, so the telemetry event reports the same values. + facts.settle(result, error) + errors = dict(facts.error_facts()) + if causes := errors.pop("error.cause_types", None): + errors["error.cause_types"] = json.dumps(causes) + return {f"airbyte.mcp.{key}": str(value) for key, value in errors.items()} + + class _StripMetaTraceContextMiddleware: async def __call__( self, @@ -228,7 +242,10 @@ async def on_call_tool( call_next: CallNext[CallToolRequestParams, ToolResult], ) -> ToolResult: attrs: dict[str, str | bool] = {} + facts = None try: + # Found by message identity, so before intent stripping copies it. + facts = tool_call_facts(context) if context.fastmcp_context and context.fastmcp_context.request_context: meta = context.fastmcp_context.request_context.meta if meta: @@ -251,13 +268,14 @@ async def on_call_tool( attrs = self._attributes(context, intent) except Exception: logger.debug("Intent attributes unavailable") - return await self._trace_call(context, call_next, attrs) + return await self._trace_call(context, call_next, attrs, facts) async def _trace_call( self, context: MiddlewareContext[CallToolRequestParams], call_next: CallNext[CallToolRequestParams, ToolResult], attrs: dict[str, str | bool], # noqa: ARG002 + facts: ToolCallFacts | None = None, # noqa: ARG002 ) -> ToolResult: return await call_next(context) @@ -389,6 +407,7 @@ async def _trace_call( context: MiddlewareContext[CallToolRequestParams], call_next: CallNext[CallToolRequestParams, ToolResult], attrs: dict[str, str | bool], + facts: ToolCallFacts | None = None, ) -> ToolResult: """Reuse common intent/action extraction without creating an OTel span.""" from ddtrace import tracer # noqa: PLC0415 @@ -421,11 +440,15 @@ async def _trace_call( ) except Exception: logger.debug("Datadog argument tracing unavailable") + result = error = None try: - return await call_next(context) + result = await call_next(context) except BaseException as exc: + error = exc attrs.update(_exception_attributes(exc)) raise + else: + return result finally: _INTENT_ATTRIBUTES.reset(token) try: @@ -438,6 +461,7 @@ async def _trace_call( if not isinstance(sys.exc_info()[1], asyncio.CancelledError): await enrich_call_scope(context.fastmcp_context) except asyncio.CancelledError as exc: + error = exc attrs.update(_exception_attributes(exc)) raise finally: @@ -451,7 +475,12 @@ async def _trace_call( if value is not None } ) + if facts is not None: + attrs.update(_error_fact_attributes(facts, result, error)) _annotate_attributes(span, attrs) + if status := attrs.get("airbyte.mcp.upstream.status_code"): + # Numeric like the OTel backend's attribute. + span.set_metric("airbyte.mcp.upstream.status_code", int(status)) except Exception: logger.debug("Datadog tool attributes unavailable") diff --git a/pyproject.toml b/pyproject.toml index 1319406d9..cb0bd7a00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - "fastmcp-extensions>=0.34.0,<1.0.0", + "fastmcp-extensions==0.34.0.dev14837991475662", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 34a0add5b..4e413e39f 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -532,6 +532,15 @@ def execute( def run_sql_query() -> str: return "private synthetic result" + @app.tool() + def fail_tool(upstream: bool = False) -> str: + if not upstream: + raise KeyError(private_payload) + response = SimpleNamespace(status_code=500) + raise RuntimeError(private_payload) from requests.HTTPError( + private_payload, response=response + ) + @app.tool() async def handle_error() -> str: from fastmcp.exceptions import ToolError @@ -687,6 +696,57 @@ async def run(): ) assert metadata["outcome"] == "success" + # A failed call carries the library's error facts, the same ones + # as its telemetry event, and no exception message. + for arguments, expected, status in ( + ( + {"upstream": True, "intent": "Fail upstream"}, + { + "error.category": "upstream_error", + "error.fault": "upstream", + "error.cause_types": '["HTTPError"]', + }, + 500, + ), + ( + {}, + {"error.category": "unclassified", "error.fault": "unknown"}, + None, + ), + ): + response = await client.post( + "/mcp", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 41, + "method": "tools/call", + "params": {"name": "fail_tool", "arguments": arguments}, + }, + ) + assert response.json()["result"]["isError"] + failed = [span for span in spans if span.span_type == "llm"][-1] + facts = { + key: failed.get_tag(f"airbyte.mcp.{key}") + for key in ( + "error.category", + "error.fault", + "error.cause_types", + ) + } + assert facts == {"error.cause_types": None, **expected} + assert ( + failed.get_metric("airbyte.mcp.upstream.status_code") == status + ) + assert ( + failed.get_tag("error.message") == "tool resulted in an error" + ) + assert ( + records[-1].extra["error_category"] + == expected["error.category"] + ) + assert records[-1].extra.get("upstream_status_code") == status + # An exception escaping the protocol boundary must retain its class/status # without exporting a payload-containing message or traceback. async def fail(_ctx): diff --git a/uv.lock b/uv.lock index 0becdf0e2..dc0b1a0ed 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = ">=0.34.0,<1.0.0" }, + { name = "fastmcp-extensions", specifier = "==0.34.0.dev14837991475662" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.34.0" +version = "0.34.0.dev14837991475662" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/ee/4271c98fcd6659bc3831b9a4d2c7629bca7b8279ecdb3c544417ef6bcbb8/fastmcp_extensions-0.34.0.tar.gz", hash = "sha256:37246e902e5eef30d514cbe95d8a1e98e066b0e5146efe5025656c46282d5b61", size = 353341, upload-time = "2026-10-06T23:16:03.497Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/71/fb2f165ae78a03b73fd6b479b7fdd66fa992aeb58d86629b39a3749ff99e/fastmcp_extensions-0.34.0.dev14837991475662.tar.gz", hash = "sha256:59ae0e5a8b3c003118f3e2a85cdb3b787e4c65de50032934816d6318e10edfd2", size = 358134, upload-time = "2026-10-09T21:08:04.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/df/fc52f76c9ba8340c3b2f7548c46da081d03be300dc60cbd5328ce71f26e5/fastmcp_extensions-0.34.0-py3-none-any.whl", hash = "sha256:cdf1b3aafe4a3342a2073c8e7101b3949e76fa052d5996ad70e2d49f50f64562", size = 157650, upload-time = "2026-10-06T23:16:01.745Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/0915fa3c9b7c3cf49157219efa61b83a9af7fb88d721d0e33a7f7c0f9c94/fastmcp_extensions-0.34.0.dev14837991475662-py3-none-any.whl", hash = "sha256:a5a480b31e6ba8354b772db3ae7940d338c0d67c5071747ca3c06176f9350691", size = 160156, upload-time = "2026-10-09T21:08:03.057Z" }, ] [[package]] From 1689608d8f3c571b3593fd9bb56b2e57b23a2de5 Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 9 Oct 2026 14:29:00 -0700 Subject: [PATCH 03/22] feat(mcp): report the API problem slug as error.reason on both tracing backends The slug lookup moves to _error_handling and is passed to the library as the error_reason hook, so the OTel backends export it too. The Datadog tag is renamed from error.problem_type to error.reason to match. Pins the fastmcp-extensions prerelease that adds the hook. Co-Authored-By: Claude Opus 5.5 --- airbyte/mcp/_datadog.py | 51 ++++++---------------------- airbyte/mcp/_error_handling.py | 40 ++++++++++++++++++++++ airbyte/mcp/_otel.py | 3 +- pyproject.toml | 2 +- tests/unit_tests/test_mcp_datadog.py | 10 +++--- tests/unit_tests/test_mcp_otel.py | 3 +- uv.lock | 8 ++--- 7 files changed, 64 insertions(+), 53 deletions(-) diff --git a/airbyte/mcp/_datadog.py b/airbyte/mcp/_datadog.py index 4df844730..961879e7a 100644 --- a/airbyte/mcp/_datadog.py +++ b/airbyte/mcp/_datadog.py @@ -17,7 +17,7 @@ from fastmcp.server.middleware import Middleware from fastmcp.telemetry import suppress_fastmcp_telemetry from fastmcp_extensions.otel._arg_digests import ArgTracer, is_arg_key # noqa: PLC2701 -from fastmcp_extensions.otel._extras import _chain, error_attributes # noqa: PLC2701 +from fastmcp_extensions.otel._extras import error_attributes # noqa: PLC2701 from fastmcp_extensions.otel.middleware import arg_trace_attributes from mcp.types import ( CallToolResult, @@ -28,8 +28,11 @@ ) from opentelemetry.instrumentation.requests import RequestsInstrumentor -from airbyte._util.api_util import error_response_body -from airbyte.mcp._error_handling import MCP_TOOL_USER_FACING_ERRORS, classify_mcp_tool_error +from airbyte.mcp._error_handling import ( + MCP_TOOL_USER_FACING_ERRORS, + classify_mcp_tool_error, + mcp_tool_error_reason, +) from airbyte.mcp._otel import _arg_key, _env, _flag from airbyte.mcp._scope import current_call_scope, scope_from_request from airbyte.mcp._trace_attributes import agent_action_attributes @@ -75,10 +78,6 @@ _ARG_TRACER = ArgTracer(_ARG_PREFIX, key=_arg_key, skip=(INTENT_ARG,)) _TOOL_ERROR_MESSAGE = "tool resulted in an error" _STATUS_CODE = "airbyte.mcp.upstream.status_code" -_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") -# The last path segment of the generic problem `type` URL; `title` is the slug then. -_GENERIC_PROBLEM_SLUG = "errors" -_MAX_PROBLEM_BODY = 65536 def _build_tool_maps() -> None: @@ -194,58 +193,28 @@ def _exception_attributes(error: BaseException) -> dict[str, str]: def _error_classification(cause: BaseException | None) -> dict[str, str]: - """Return the bounded category, fault, cause types, status, and problem type of a failure. + """Return the bounded category, fault, cause types, status, and reason of a failure. `cause` is `None` for a tool that returned an error result. These are the - OTel backend's `error_attributes`, plus the Airbyte API problem slug. + OTel backend's `error_attributes`, with the same classifier and reason hook. """ try: errors = error_attributes( cause, user_facing_errors=MCP_TOOL_USER_FACING_ERRORS, classifier=classify_mcp_tool_error, + reason=mcp_tool_error_reason, ) if causes := errors.pop("error.cause_types", None): # A tuple of class names; a JSON array is what a tag can carry. errors["error.cause_types"] = json.dumps(causes) attrs = {f"airbyte.mcp.{key}": str(value) for key, value in errors.items()} - problem_type = _problem_type(cause) if cause is not None else None except Exception: logger.debug("Datadog error classification unavailable") return {} - if problem_type is not None: - attrs["airbyte.mcp.error.problem_type"] = problem_type return attrs -def _problem_type(cause: BaseException) -> str | None: - """Return the problem slug from the first Airbyte API error body in the chain. - - Only the problem `type`, or its `title` when `type` is the generic errors - page, is read. `detail` and `data` quote IDs and payloads, so they never are. - """ - body = next( - (body for body in map(error_response_body, _chain(cause)) if body is not None), - None, - ) - if body is None or len(body) > _MAX_PROBLEM_BODY: - return None - try: - problem = json.loads(body) - except (ValueError, RecursionError): - return None - if not isinstance(problem, dict): - return None - for field in ("type", "title"): - value = problem.get(field) - if not isinstance(value, str): - continue - slug = re.split(r"[/#]", value)[-1] - if slug != _GENERIC_PROBLEM_SLUG and _PROBLEM_SLUG.fullmatch(slug): - return slug - return None - - def _tool_error_message(span: Span) -> str: """Summarize a tool error from its bounded classification tags only.""" status = span.get_metric(_STATUS_CODE) @@ -254,7 +223,7 @@ def _tool_error_message(span: Span) -> str: for detail in ( span.get_tag("airbyte.mcp.error.category"), f"HTTP {int(status)}" if status else None, - span.get_tag("airbyte.mcp.error.problem_type"), + span.get_tag("airbyte.mcp.error.reason"), ) if detail ) diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index f9ee68f24..d525000f1 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -3,6 +3,12 @@ from __future__ import annotations +import json +import re + +from fastmcp_extensions.otel._extras import _chain # noqa: PLC2701 + +from airbyte._util.api_util import error_response_body from airbyte.exceptions import ( AirbyteAgentsUnavailableError, AirbyteConnectionSyncError, @@ -55,3 +61,37 @@ def classify_mcp_tool_error(error: BaseException) -> str | None: if isinstance(error, AirbyteConnectionSyncTimeoutError): return "upstream_timeout" return "upstream_error" + + +_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") +# The last path segment of the generic problem `type` URL; `title` is the slug then. +_GENERIC_PROBLEM_SLUG = "errors" +_MAX_PROBLEM_BODY = 65536 + + +def mcp_tool_error_reason(error: BaseException) -> str | None: + """Return the problem slug from the first Airbyte API error body in the chain. + + Only the problem `type`, or its `title` when `type` is the generic errors + page, is read. `detail` and `data` quote IDs and payloads, so they never are. + """ + body = next( + (body for body in map(error_response_body, _chain(error)) if body is not None), + None, + ) + if body is None or len(body) > _MAX_PROBLEM_BODY: + return None + try: + problem = json.loads(body) + except (ValueError, RecursionError): + return None + if not isinstance(problem, dict): + return None + for field in ("type", "title"): + value = problem.get(field) + if not isinstance(value, str): + continue + slug = re.split(r"[/#]", value)[-1] + if slug != _GENERIC_PROBLEM_SLUG and _PROBLEM_SLUG.fullmatch(slug): + return slug + return None diff --git a/airbyte/mcp/_otel.py b/airbyte/mcp/_otel.py index 51110ae5b..e9cdc6b90 100644 --- a/airbyte/mcp/_otel.py +++ b/airbyte/mcp/_otel.py @@ -21,7 +21,7 @@ from opentelemetry.trace import SpanKind, Status from airbyte.constants import CLOUD_API_ROOT, CLOUD_CONFIG_API_ROOT -from airbyte.mcp._error_handling import classify_mcp_tool_error +from airbyte.mcp._error_handling import classify_mcp_tool_error, mcp_tool_error_reason if TYPE_CHECKING: @@ -314,6 +314,7 @@ def install(app: FastMCP, *, environ: Mapping[str, str] | None = None) -> None: ), capture_intent=_flag(environ, "AIRBYTE_MCP_INTENT_CAPTURE"), error_classifier=classify_mcp_tool_error, + error_reason=mcp_tool_error_reason, other_spans=_http_client_span_attributes, arg_key=_arg_key, exporter=_exporter(backend, environ), diff --git a/pyproject.toml b/pyproject.toml index cb0bd7a00..db9cb9a99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - "fastmcp-extensions==0.34.0.dev14837991475662", + "fastmcp-extensions==0.34.0.dev14837993197853", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index e73a0c6a5..d9ed38c4f 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -210,7 +210,7 @@ def _problem(problem_type: str, title: str) -> str: "airbyte.mcp.error.fault": "upstream", "airbyte.mcp.error.cause_types": '["SDKError"]', "airbyte.mcp.upstream.status_code": "500", - "airbyte.mcp.error.problem_type": "unexpected-problem", + "airbyte.mcp.error.reason": "unexpected-problem", }, id="server-error-uses-title-of-generic-problem", ), @@ -228,7 +228,7 @@ def _problem(problem_type: str, title: str) -> str: "airbyte.mcp.error.fault": "upstream", "airbyte.mcp.error.cause_types": '["SDKError"]', "airbyte.mcp.upstream.status_code": "400", - "airbyte.mcp.error.problem_type": "error:connection-conflicting-destination-stream", + "airbyte.mcp.error.reason": "error:connection-conflicting-destination-stream", }, id="specific-problem-type", ), @@ -243,7 +243,7 @@ def _problem(problem_type: str, title: str) -> str: "airbyte.mcp.error.fault": "caller", "airbyte.mcp.error.cause_types": '["SDKError"]', "airbyte.mcp.upstream.status_code": "409", - "airbyte.mcp.error.problem_type": "409-actor-not-ready", + "airbyte.mcp.error.reason": "409-actor-not-ready", }, id="problem-type-fragment", ), @@ -307,7 +307,7 @@ def _problem(problem_type: str, title: str) -> str: "airbyte.mcp.error.category": "auth", "airbyte.mcp.error.fault": "caller", "airbyte.mcp.upstream.status_code": "403", - "airbyte.mcp.error.problem_type": "forbidden", + "airbyte.mcp.error.reason": "forbidden", }, id="config-api-error-response-text", ), @@ -380,7 +380,7 @@ def test_returned_tool_error_is_classified_as_tool_error() -> None: { "airbyte.mcp.error.category": "upstream_error", "airbyte.mcp.upstream.status_code": 500, - "airbyte.mcp.error.problem_type": "unexpected-problem", + "airbyte.mcp.error.reason": "unexpected-problem", }, "tool resulted in an error (upstream_error, HTTP 500, unexpected-problem)", id="full", diff --git a/tests/unit_tests/test_mcp_otel.py b/tests/unit_tests/test_mcp_otel.py index 87b33d9ec..5b4c831bf 100644 --- a/tests/unit_tests/test_mcp_otel.py +++ b/tests/unit_tests/test_mcp_otel.py @@ -30,7 +30,7 @@ from airbyte._util import meta from airbyte.constants import CLOUD_API_ROOT from airbyte.mcp import _otel as observability -from airbyte.mcp._error_handling import classify_mcp_tool_error +from airbyte.mcp._error_handling import classify_mcp_tool_error, mcp_tool_error_reason from airbyte.mcp import _scope from airbyte.mcp._scope import ( CallScopeMiddleware, @@ -430,6 +430,7 @@ def register(app_arg: object, config: TelemetryConfig) -> None: ) assert tracing.capture_intent is True assert tracing.error_classifier is classify_mcp_tool_error + assert tracing.error_reason is mcp_tool_error_reason assert tracing.other_spans is observability._http_client_span_attributes assert tracing.arg_key is observability._arg_key assert tracing.exporter == "otlp" diff --git a/uv.lock b/uv.lock index dc0b1a0ed..ea4287d5a 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = "==0.34.0.dev14837991475662" }, + { name = "fastmcp-extensions", specifier = "==0.34.0.dev14837993197853" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.34.0.dev14837991475662" +version = "0.34.0.dev14837993197853" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/71/fb2f165ae78a03b73fd6b479b7fdd66fa992aeb58d86629b39a3749ff99e/fastmcp_extensions-0.34.0.dev14837991475662.tar.gz", hash = "sha256:59ae0e5a8b3c003118f3e2a85cdb3b787e4c65de50032934816d6318e10edfd2", size = 358134, upload-time = "2026-10-09T21:08:04.886Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/17/94b9f61ef2d8d18e31b8ec26bf02f1d871eabcdf304ff94ed9cde7574bd4/fastmcp_extensions-0.34.0.dev14837993197853.tar.gz", hash = "sha256:d3cf2c6b13707fb034e5674e8b09d38230946f747cf7e57243618ea6519398e6", size = 359109, upload-time = "2026-10-09T21:24:29.258Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/8a/0915fa3c9b7c3cf49157219efa61b83a9af7fb88d721d0e33a7f7c0f9c94/fastmcp_extensions-0.34.0.dev14837991475662-py3-none-any.whl", hash = "sha256:a5a480b31e6ba8354b772db3ae7940d338c0d67c5071747ca3c06176f9350691", size = 160156, upload-time = "2026-10-09T21:08:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5c/46722187bfae6751150bfa0393b958308d5ad4ca1a076f4aa8949de1a96b/fastmcp_extensions-0.34.0.dev14837993197853-py3-none-any.whl", hash = "sha256:74760433044b6278448f5a335fb5a3997c8222790fd6930bdd88e877042a5a28", size = 160679, upload-time = "2026-10-09T21:24:27.37Z" }, ] [[package]] From f3a57701c95f582b68926ef7d3fd3b91cdb9e569 Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 9 Oct 2026 14:39:52 -0700 Subject: [PATCH 04/22] fix(mcp): never export an ID-shaped problem slug as the error reason Co-Authored-By: Claude Opus 5.5 --- airbyte/mcp/_error_handling.py | 8 +++++- tests/unit_tests/test_mcp_tool_errors.py | 32 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index d525000f1..fa2ec210e 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -66,6 +66,8 @@ def classify_mcp_tool_error(error: BaseException) -> str | None: _PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") # The last path segment of the generic problem `type` URL; `title` is the slug then. _GENERIC_PROBLEM_SLUG = "errors" +# A slug names a kind of problem; one that is or holds an ID names a resource. +_ID_LIKE = re.compile(r"\d+|.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*") _MAX_PROBLEM_BODY = 65536 @@ -92,6 +94,10 @@ def mcp_tool_error_reason(error: BaseException) -> str | None: if not isinstance(value, str): continue slug = re.split(r"[/#]", value)[-1] - if slug != _GENERIC_PROBLEM_SLUG and _PROBLEM_SLUG.fullmatch(slug): + if ( + slug != _GENERIC_PROBLEM_SLUG + and _PROBLEM_SLUG.fullmatch(slug) + and not _ID_LIKE.fullmatch(slug) + ): return slug return None diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index 1bff852c6..89a4b5f0d 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -4,12 +4,14 @@ from __future__ import annotations import asyncio +import json import pytest from fastmcp import Client from fastmcp.exceptions import ToolError from airbyte.exceptions import ( + AirbyteCloudApiError, AirbyteConnectionSyncError, AirbyteConnectionSyncTimeoutError, AirbyteConnectorInUseError, @@ -21,6 +23,7 @@ MCP_TOOL_USER_FACING_ERRORS, classify_mcp_tool_error, format_user_facing_error, + mcp_tool_error_reason, ) from fastmcp_extensions import mcp_server @@ -139,3 +142,32 @@ def test_format_user_facing_error() -> None: ) def test_classify_mcp_tool_error(error: BaseException, expected: str | None) -> None: assert classify_mcp_tool_error(error) == expected + + +@pytest.mark.parametrize( + ("problem", "expected"), + [ + ( + {"type": "https://h/errors", "title": "unexpected-problem"}, + "unexpected-problem", + ), + # A slug that is, or holds, an ID names a resource and is never exported. + ({"type": "https://h/errors", "title": "1234567"}, None), + ( + {"type": "https://h/v1/workspaces/97953b90-8f3a-4c1e-9d2b-0a1b2c3d4e5f"}, + None, + ), + ( + { + "type": "https://h/errors", + "title": "ws-97953b90-8f3a-4c1e-9d2b-0a1b2c3d4e5f", + }, + None, + ), + ], +) +def test_mcp_tool_error_reason_is_never_an_id( + problem: dict[str, str], expected: str | None +) -> None: + error = AirbyteCloudApiError(context={"response_text": json.dumps(problem)}) + assert mcp_tool_error_reason(error) == expected From e89f6399db1851e1ddda102100f818e69ca30c4c Mon Sep 17 00:00:00 2001 From: Caleb Evans Date: Fri, 9 Oct 2026 15:15:59 -0700 Subject: [PATCH 05/22] fix(mcp): classify invalid arguments and unknown tools on Datadog spans like the OTel backend Only FastMCP's ToolError wrapper is unwrapped, so its ValidationError is classified as invalid_arguments instead of the chained pydantic error. A name FastMCP rejects reports outcome and category unknown_tool. Co-Authored-By: Claude Opus 5.5 --- airbyte/mcp/_datadog.py | 22 ++++++++++++++++------ tests/unit_tests/test_mcp_datadog.py | 23 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/airbyte/mcp/_datadog.py b/airbyte/mcp/_datadog.py index 961879e7a..c36ef3f91 100644 --- a/airbyte/mcp/_datadog.py +++ b/airbyte/mcp/_datadog.py @@ -14,8 +14,10 @@ from contextvars import ContextVar from typing import TYPE_CHECKING, Any +from fastmcp.exceptions import DisabledError, NotFoundError from fastmcp.server.middleware import Middleware from fastmcp.telemetry import suppress_fastmcp_telemetry +from fastmcp_extensions._telemetry_middleware import unwrap_tool_error # noqa: PLC2701 from fastmcp_extensions.otel._arg_digests import ArgTracer, is_arg_key # noqa: PLC2701 from fastmcp_extensions.otel._extras import error_attributes # noqa: PLC2701 from fastmcp_extensions.otel.middleware import arg_trace_attributes @@ -180,19 +182,26 @@ def _request_trace_attributes() -> dict[str, str]: def _exception_attributes(error: BaseException) -> dict[str, str]: - cause = error.__cause__ or error + # The OTel backend's rules: only FastMCP's `ToolError` wrapper is removed, + # and a name FastMCP itself rejected is an unknown tool. + cause = unwrap_tool_error(error) + unknown_tool = isinstance(error, (NotFoundError, DisabledError)) + if isinstance(error, asyncio.CancelledError): + outcome = "cancelled" + else: + outcome = "unknown_tool" if unknown_tool else "exception" error_type = type(cause).__name__ return { - "airbyte.mcp.outcome": "cancelled" - if isinstance(error, asyncio.CancelledError) - else "exception", + "airbyte.mcp.outcome": outcome, "airbyte.mcp.error_type": error_type, "error.type": error_type, - **_error_classification(cause), + **_error_classification(cause, unknown_tool=unknown_tool), } -def _error_classification(cause: BaseException | None) -> dict[str, str]: +def _error_classification( + cause: BaseException | None, *, unknown_tool: bool = False +) -> dict[str, str]: """Return the bounded category, fault, cause types, status, and reason of a failure. `cause` is `None` for a tool that returned an error result. These are the @@ -201,6 +210,7 @@ def _error_classification(cause: BaseException | None) -> dict[str, str]: try: errors = error_attributes( cause, + unknown_tool=unknown_tool, user_facing_errors=MCP_TOOL_USER_FACING_ERRORS, classifier=classify_mcp_tool_error, reason=mcp_tool_error_reason, diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index d9ed38c4f..661abfc82 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -12,7 +12,7 @@ import pytest import requests from airbyte_api.errors import SDKError -from fastmcp.exceptions import ToolError +from fastmcp.exceptions import NotFoundError, ToolError, ValidationError from airbyte._util import api_util, meta from airbyte.exceptions import ( @@ -366,6 +366,27 @@ def test_exception_attributes_classify_without_error_text( assert "SENTINEL" not in json.dumps(attrs) +def test_fastmcp_rejections_are_classified_like_the_otel_backend() -> None: + # FastMCP chains its own `ValidationError` from pydantic's; only a + # `ToolError` wrapper is removed, so the FastMCP class is what is classified. + invalid = ValidationError("SENTINEL") + invalid.__cause__ = ValueError("SENTINEL") + attrs = _datadog._exception_attributes(invalid) + assert (attrs["airbyte.mcp.outcome"], attrs["airbyte.mcp.error.category"]) == ( + "exception", + "invalid_arguments", + ) + + attrs = _datadog._exception_attributes(NotFoundError("SENTINEL")) + assert attrs == { + "airbyte.mcp.outcome": "unknown_tool", + "airbyte.mcp.error_type": "NotFoundError", + "error.type": "NotFoundError", + "airbyte.mcp.error.category": "unknown_tool", + "airbyte.mcp.error.fault": "caller", + } + + def test_returned_tool_error_is_classified_as_tool_error() -> None: assert _datadog._error_classification(None) == { "airbyte.mcp.error.category": "tool_error", From bfceadbbf52201a85f68f78231bebe743a2754f1 Mon Sep 17 00:00:00 2001 From: "Aaron (\"AJ\") Steers" Date: Fri, 9 Oct 2026 15:53:38 -0700 Subject: [PATCH 06/22] Apply suggestion from @aaronsteers --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index db9cb9a99..d9f3ebe7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", + # TK-TODO: Needs a release and re-pin before merging "fastmcp-extensions==0.34.0.dev14837993197853", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly From d74eeb3070a6d6d29e70699d83b9cc5b50b71574 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 03:28:16 +0000 Subject: [PATCH 07/22] feat(mcp): export a frames-only error stack on Datadog tool spans Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/mcp/_datadog.py | 10 ++++++++-- pyproject.toml | 2 +- tests/unit_tests/test_mcp_datadog.py | 30 ++++++++++++++++++++++++++-- uv.lock | 8 ++++---- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/airbyte/mcp/_datadog.py b/airbyte/mcp/_datadog.py index c36ef3f91..f4cc8af21 100644 --- a/airbyte/mcp/_datadog.py +++ b/airbyte/mcp/_datadog.py @@ -19,7 +19,10 @@ from fastmcp.telemetry import suppress_fastmcp_telemetry from fastmcp_extensions._telemetry_middleware import unwrap_tool_error # noqa: PLC2701 from fastmcp_extensions.otel._arg_digests import ArgTracer, is_arg_key # noqa: PLC2701 -from fastmcp_extensions.otel._extras import error_attributes # noqa: PLC2701 +from fastmcp_extensions.otel._extras import ( + error_attributes, # noqa: PLC2701 + error_stack, # noqa: PLC2701 +) from fastmcp_extensions.otel.middleware import arg_trace_attributes from mcp.types import ( CallToolResult, @@ -191,12 +194,15 @@ def _exception_attributes(error: BaseException) -> dict[str, str]: else: outcome = "unknown_tool" if unknown_tool else "exception" error_type = type(cause).__name__ - return { + attrs = { "airbyte.mcp.outcome": outcome, "airbyte.mcp.error_type": error_type, "error.type": error_type, **_error_classification(cause, unknown_tool=unknown_tool), } + if (stack := error_stack(cause)) is not None: + attrs["error.stack"] = stack + return attrs def _error_classification( diff --git a/pyproject.toml b/pyproject.toml index d9f3ebe7f..f8729be09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", # TK-TODO: Needs a release and re-pin before merging - "fastmcp-extensions==0.34.0.dev14837993197853", + "fastmcp-extensions==0.34.0.dev14838020136172", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 661abfc82..809b17110 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -366,6 +366,28 @@ def test_exception_attributes_classify_without_error_text( assert "SENTINEL" not in json.dumps(attrs) +def test_exception_attributes_export_frames_without_error_text() -> None: + def raise_chained() -> None: + try: + raise ValueError("SENTINEL-MSG inner private response") + except ValueError as cause: + raise RuntimeError("SENTINEL-MSG outer private response") from cause + + with pytest.raises(RuntimeError) as raised: + raise_chained() + + attrs = _datadog._exception_attributes(raised.value) + stack = attrs["error.stack"] + lines = stack.splitlines() + + assert lines[0] == "RuntimeError" + assert "caused by ValueError" in lines + assert any(line.startswith(f" {__name__}:") for line in lines) + assert "SENTINEL" not in stack + assert "inner private response" not in stack + assert "outer private response" not in stack + + def test_fastmcp_rejections_are_classified_like_the_otel_backend() -> None: # FastMCP chains its own `ValidationError` from pydantic's; only a # `ToolError` wrapper is removed, so the FastMCP class is what is classified. @@ -1000,10 +1022,13 @@ async def fail(_ctx): escaped = next(span for span in reversed(spans) if span.span_type == "llm") assert escaped.error and escaped.get_tag("error.type") == "ValueError" assert escaped.get_tag("error.message") is None - assert escaped.get_tag("error.stack") is None + escaped_stack = escaped.get_tag("error.stack") + assert escaped_stack and escaped_stack.startswith("ValueError\n") + assert private_payload not in escaped_stack escaped_metadata = escaped._get_ctx_item("_llmobs.cached_event")["meta"][ "metadata" ] + assert "error.stack" not in escaped_metadata assert escaped_metadata["outcome"] == "exception" assert escaped_metadata["error_type"] == "ValueError" assert escaped_metadata["error.category"] == "unclassified" @@ -1085,7 +1110,8 @@ async def dispatch(_ctx): metadata["error_type"] == span.get_tag("error.type") == "CancelledError" ) assert metadata["intent"] == "Preserve safe metadata on cancellation" - assert span.get_tag("error.stack") is None + stack = span.get_tag("error.stack") + assert stack and stack.startswith("CancelledError\n") try: with ( diff --git a/uv.lock b/uv.lock index ea4287d5a..92051b4b3 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = "==0.34.0.dev14837993197853" }, + { name = "fastmcp-extensions", specifier = "==0.34.0.dev14838020136172" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.34.0.dev14837993197853" +version = "0.34.0.dev14838020136172" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/17/94b9f61ef2d8d18e31b8ec26bf02f1d871eabcdf304ff94ed9cde7574bd4/fastmcp_extensions-0.34.0.dev14837993197853.tar.gz", hash = "sha256:d3cf2c6b13707fb034e5674e8b09d38230946f747cf7e57243618ea6519398e6", size = 359109, upload-time = "2026-10-09T21:24:29.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/ae/539451bddee43df494e055015a4e2dcb84dba79c21cf54501c8776485044/fastmcp_extensions-0.34.0.dev14838020136172.tar.gz", hash = "sha256:0fc3d2b27a798f060ad58dab5dfa8bb5bed5a835824f760a35d73c854559c5d4", size = 361509, upload-time = "2026-10-10T03:19:45.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5c/46722187bfae6751150bfa0393b958308d5ad4ca1a076f4aa8949de1a96b/fastmcp_extensions-0.34.0.dev14837993197853-py3-none-any.whl", hash = "sha256:74760433044b6278448f5a335fb5a3997c8222790fd6930bdd88e877042a5a28", size = 160679, upload-time = "2026-10-09T21:24:27.37Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8a/f788ad5630c3ee334dad83ec0e0a17e309badc34793e71ec53b4a6920410/fastmcp_extensions-0.34.0.dev14838020136172-py3-none-any.whl", hash = "sha256:c55626692eeb07b73b754c0ed3482f86d14bd73178d879cd1ce2d63f0c7aeccf", size = 161830, upload-time = "2026-10-10T03:19:43.727Z" }, ] [[package]] From 82d5db099d9465f8bc5e35c438f9bff4d627c58e Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 03:46:11 +0000 Subject: [PATCH 08/22] chore(ci): suppress intentional release pin TODO Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f8729be09..90c1ff411 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - # TK-TODO: Needs a release and re-pin before merging + # TK-TODO: Needs a release and re-pin before merging. IGNORE:TK "fastmcp-extensions==0.34.0.dev14838020136172", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly From a0f60ed819da797748ded8f44e518836b3ed24c1 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 03:59:15 +0000 Subject: [PATCH 09/22] Revert "chore(ci): suppress intentional release pin TODO" This reverts commit 82d5db099d9465f8bc5e35c438f9bff4d627c58e. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90c1ff411..f8729be09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - # TK-TODO: Needs a release and re-pin before merging. IGNORE:TK + # TK-TODO: Needs a release and re-pin before merging "fastmcp-extensions==0.34.0.dev14838020136172", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly From a7cd0d3ba4ee96c0567c6d2bfbc62a560858fd87 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 04:19:00 +0000 Subject: [PATCH 10/22] feat(mcp): give agents actionable Cloud API errors and mask echoed values Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_connector_base.py | 7 +- airbyte/_util/api_util.py | 470 +++++++++++++++++++++-- airbyte/mcp/_error_handling.py | 238 ++++++++++-- airbyte/mcp/cloud.py | 4 +- airbyte/mcp/local.py | 33 +- airbyte/mcp/server.py | 13 + tests/unit_tests/test_cloud_api_util.py | 309 ++++++++++++++- tests/unit_tests/test_connector_base.py | 45 +++ tests/unit_tests/test_mcp_datadog.py | 21 + tests/unit_tests/test_mcp_otel.py | 229 ++++++++++- tests/unit_tests/test_mcp_tool_errors.py | 148 ++++++- 11 files changed, 1427 insertions(+), 90 deletions(-) create mode 100644 tests/unit_tests/test_connector_base.py diff --git a/airbyte/_connector_base.py b/airbyte/_connector_base.py index b33dfbb88..d14eb0591 100644 --- a/airbyte/_connector_base.py +++ b/airbyte/_connector_base.py @@ -185,11 +185,12 @@ 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 `{ex.json_path}` " + f"(failed the `{ex.validator}` rule)." + ), context={ - "error_message": ex.message, "error_path": ex.path, - "error_instance": ex.instance, "error_schema": ex.schema, }, ) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 360455e3e..37b919c3f 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -17,7 +17,8 @@ import copy import json import logging -from dataclasses import replace +import re +from dataclasses import dataclass, replace from http import HTTPStatus from typing import TYPE_CHECKING, Any, Literal, TypeVar @@ -171,6 +172,423 @@ def error_response_body(error: BaseException) -> str | None: return None +_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") +# The last path segment of the generic problem `type` URL; `title` is the slug then. +_GENERIC_PROBLEM_SLUG = "errors" +# A slug names a kind of problem; one that is or holds an ID names a resource. +_ID_LIKE = re.compile(r"\d+|.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*") +_MAX_PROBLEM_BODY = 65536 +_RESOURCE_TYPE = re.compile(r"[a-z_-]{1,40}") +_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + +@dataclass(frozen=True) +class CloudProblem: + """The allow-listed fields of an Airbyte Cloud error body. + + `detail`, `data.message` and every other field can quote caller values or + server internals, so they are never read for message text. + """ + + status_code: int | None + slug: str | None # Last `type`/`title` segment, like `mcp_tool_error_reason`. + resource_type: str | None # `data.resourceType`, a fixed word. + limit: int | None # `data.limit`. + error_id: str | None # Top-level `errorId` of a non-problem body. + + +def _problem_slug(problem: dict[str, Any]) -> str | None: + """Return the problem slug: the last `type` segment, or `title` for the generic type.""" + for field in ("type", "title"): + value = problem.get(field) + if not isinstance(value, str): + continue + slug = re.split(r"[/#]", value)[-1] + if ( + slug != _GENERIC_PROBLEM_SLUG + and _PROBLEM_SLUG.fullmatch(slug) + and not _ID_LIKE.fullmatch(slug) + ): + return slug + return None + + +def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProblem: + """Read only the allow-listed fields of a Cloud API error body. + + Bodies over 64 KiB or non-JSON give `slug=None`. + """ + slug = resource_type = error_id = None + limit = None + if body is not None and len(body) <= _MAX_PROBLEM_BODY: + try: + problem = json.loads(body) + except (ValueError, RecursionError): + problem = None + if isinstance(problem, dict): + slug = _problem_slug(problem) + data = problem.get("data") + if isinstance(data, dict): + candidate = data.get("resourceType") + if isinstance(candidate, str) and _RESOURCE_TYPE.fullmatch(candidate): + resource_type = candidate + limit_value = data.get("limit") + if isinstance(limit_value, int) and not isinstance(limit_value, bool): + limit = limit_value + candidate = problem.get("errorId") + if isinstance(candidate, str) and _UUID.fullmatch(candidate): + error_id = candidate + return CloudProblem( + status_code=status_code, + slug=slug, + resource_type=resource_type, + limit=limit, + error_id=error_id, + ) + + +_SCHEDULE_INVALID_PROBLEM = ( + "The connection schedule is invalid.", + "Fix the schedule (cron expression, timezone, or frequency) and retry.", +) +_MAPPER_INVALID_PROBLEM = ( + "The mapper configuration is invalid.", + "Fix the mapper configuration and retry. " + "If a mapper secret is missing, resend the secret values.", +) +_STREAM_SELECTION_PROBLEM = ( + "The selected streams can't be used with this connection (file transfer is " + "unsupported, or a stream collides with another connection).", + "Change the selected streams or connection settings and retry.", +) +_DESTINATION_CATALOG_PROBLEM = ( + "The destination catalog settings for a stream are invalid.", + "Fix the stream's destination settings (object name, sync mode, fields, " + "primary key) and retry.", +) +_VALUE_REJECTED_PROBLEM = ( + "Airbyte Cloud rejected a value in the request.", + "Fix the input and retry.", +) +_ROLLOUT_REJECTED_PROBLEM = ( + "The connector rollout request was rejected.", + "Don't retry; tell the user.", +) +_ALREADY_EXISTS_PROBLEM = ( + "A resource with this name or membership already exists.", + "Use the existing one or pick another name; don't retry as is.", +) +_CLOUD_OPERATION_FAILED_PROBLEM = ( + "Airbyte Cloud failed to complete this operation.", + "Don't retry; tell the user.", +) +_DBTCLOUD_PROBLEM = ( + "dbt Cloud rejected the integration credentials, requires a paid plan, " "or the call failed.", + "Ask the user to check the dbt Cloud integration.", +) +_ENDPOINT_UNAVAILABLE_PROBLEM = ( + "This operation isn't available at this Airbyte endpoint.", + "Don't retry.", +) +_RESOURCE_NOT_FOUND_PROBLEM = ( + "The resource was not found.", + "Check the ID; list the resources to find the right one.", +) +_INVALID_API_KEY_PROBLEM = ( + "Airbyte Cloud rejected the credentials.", + "Ask the user to check the client ID/secret or token; don't retry.", +) +_REQUEST_TIMEOUT_PROBLEM = ( + "Airbyte Cloud timed out. The change may or may not have been applied.", + "Check the resource's current state before retrying; don't blindly retry a create.", +) +_STATE_CONFLICT_PROBLEM = ( + "The resource is in a state that doesn't allow this operation (for example, " + "a job is running or already finished).", + "Check the resource's current status before retrying.", +) +_UNEXPECTED_PROBLEM = ( + "Airbyte Cloud hit an unexpected error. This is often caused by an invalid " "argument value.", + "Check the arguments (ranges, IDs, formats). If they look right, don't retry " + "more than once; tell the user.", +) +_RATE_LIMITED_PROBLEM = ( + "Airbyte Cloud is refusing requests from these credentials (rate limited).", + "Stop calling Airbyte Cloud tools and tell the user; don't retry.", +) +_REQUEST_REJECTED_PROBLEM = ( + "Airbyte Cloud rejected the request as invalid.", + "Check the arguments against the tool description.", +) +_CLOUD_ERROR_PROBLEM = ( + "Airbyte Cloud returned an error.", + "Retry at most once; tell the user if it persists.", +) + +# Each key is the problem slug: the last `type` path segment (or `title` when the +# type is the generic errors page). `notification/config/required` and +# `connection/destination-catalog/required` collide on `required`, which takes +# the generic rejected-value wording. +_PROBLEM_MESSAGES: dict[str, tuple[str, str]] = { + "resource-not-found": _RESOURCE_NOT_FOUND_PROBLEM, + "catalog-not-found": ( + "This connection has no destination catalog yet.", + "Set up the destination catalog before this operation; do not change the ID.", + ), + "subscription-required": ( + "This organization needs an active Airbyte subscription for this operation.", + "Ask the user to check billing; don't retry.", + ), + "no-active-subscription": ( + "This organization needs an active Airbyte subscription for this operation.", + "Ask the user to check billing; don't retry.", + ), + "insufficient-payment-status": ( + "The organization's payment status or credit balance doesn't allow this.", + "Ask the user to resolve billing; don't retry.", + ), + "insufficient-credit-balance": ( + "The organization's payment status or credit balance doesn't allow this.", + "Ask the user to resolve billing; don't retry.", + ), + "invalid-api-key": _INVALID_API_KEY_PROBLEM, + "error:sso-token-validation": ( + "The SSO access token is invalid or expired.", + "Ask the user to sign in again.", + ), + "sso-required": ( + "This email domain must sign in with SSO.", + "Ask the user to sign in through SSO.", + ), + "invalid-github-token": ( + "The GitHub token is invalid or lacks repo write permission.", + "Ask the user for a valid token.", + ), + "insufficient-github-token-permissions": ( + "The GitHub token is invalid or lacks repo write permission.", + "Ask the user for a valid token.", + ), + "entitlement": ( + "The organization's plan doesn't allow this.", + "Ask the user; don't retry.", + ), + "error:workspace-limit-for-organization-reached": ( + "The organization's plan doesn't allow this.", + "Ask the user; don't retry.", + ), + "value-not-found": ( + "Airbyte doesn't recognize the connector type name.", + "Check the name against the connector list (e.g. `source-postgres`).", + ), + "bad-request": _REQUEST_REJECTED_PROBLEM, + "unprocessable-entity": ( + "The request body or connector configuration failed validation.", + "Fix the configuration and retry.", + ), + "invalid-redirect-url": ( + "The redirect URL is not a valid HTTPS URL.", + "Fix the configuration and retry.", + ), + "409-state-conflict": _STATE_CONFLICT_PROBLEM, + "locked": ( + "The connection is locked and can't be changed right now.", + "Don't retry; ask the user.", + ), + "try-again-later": ( + "Airbyte Cloud is busy with a conflicting change.", + "Wait and retry once.", + ), + "error:group-managed-by-scim": ( + "This group is managed by SCIM and can't be changed here.", + "Don't retry; tell the user.", + ), + "runtime-secrets-manager-required": ( + "This needs a runtime secrets manager configured.", + "Ask the user.", + ), + "error:failed-precondition": ( + "A required condition for this operation isn't met.", + "Check the resource's state; ask the user if unclear.", + ), + "error:request-timeout-exceeded": _REQUEST_TIMEOUT_PROBLEM, + "unexpected-problem": _UNEXPECTED_PROBLEM, + "service-unavailable": ( + "Airbyte Cloud is temporarily unavailable.", + "Wait and retry once.", + ), + "error-adding-organization": ( + "Airbyte Cloud is temporarily unavailable.", + "Wait and retry once.", + ), + "error:tag-limit-for-workspace-reached": ( + "The workspace has reached its tag limit.", + "Delete a tag first or ask the user.", + ), +} +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "missing-cron-data", + "missing-component", + "unsupported-timezone", + "invalid-expression", + "invalid-timezone", + "under-one-hour-not-allowed", + ), + _SCHEDULE_INVALID_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "error:mapper-validation", + "missing-required-param", + "invalid-config", + "secret-not-found", + ), + _MAPPER_INVALID_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "connection-unsupported", + "stream-unsupported", + "error:connection-conflicting-destination-stream", + ), + _STREAM_SELECTION_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "missing-object-name", + "invalid-operation", + "missing-required-field", + "invalid-additional-field", + "missing-primary-key", + "invalid-primary-key", + "discover-not-supported", + ), + _DESTINATION_CATALOG_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "error:tag-invalid-hex-color", + "error:tag-name-too-long", + "required", + "missing-url", + ), + _VALUE_REJECTED_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ("invalid-request", "rollout-percentage-reached", "not-enough-actors"), + _ROLLOUT_REJECTED_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "user-already-exists", + "error:tag-already-exists", + "error:group-already-exists", + "error:group-permission-already-exists", + "error:group-member-already-exists", + "error:dataplane-group-name-already-exists", + "error:dataplane-name-already-exists", + "connector-image-name-in-use", + ), + _ALREADY_EXISTS_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "oauth-callback-failure", + "invalid-consent-url", + "error:generate-contribution", + "error:sso-config-retrieval", + "error:sso-setup", + "error:sso-deletion", + "error:sso-credential-update", + "error:sso-activation", + "invalid-organization-state", + ), + _CLOUD_OPERATION_FAILED_PROBLEM, + ) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys(("access-denied", "paid-plan-required", "generic"), _DBTCLOUD_PROBLEM) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys(("endpoint-moved", "not-implemented-in-oss"), _ENDPOINT_UNAVAILABLE_PROBLEM) +) +_PROBLEM_MESSAGES.update( + dict.fromkeys( + ( + "no-cancelable-subscription", + "no-scheduled-cancellation-subscription", + "no-scheduled-plan-change", + ), + ( + "There is no subscription change of this kind to act on.", + "Don't retry; tell the user.", + ), + ) +) + + +_STATUS_FALLBACK_PROBLEMS: dict[int, tuple[str, str]] = { + HTTPStatus.UNAUTHORIZED: _INVALID_API_KEY_PROBLEM, + HTTPStatus.NOT_FOUND: _RESOURCE_NOT_FOUND_PROBLEM, + HTTPStatus.REQUEST_TIMEOUT: _REQUEST_TIMEOUT_PROBLEM, + HTTPStatus.CONFLICT: _STATE_CONFLICT_PROBLEM, + HTTPStatus.TOO_MANY_REQUESTS: _RATE_LIMITED_PROBLEM, +} + + +def _status_fallback_message(status_code: int | None) -> tuple[str, str]: + """Return (message, guidance) for a status without a known problem slug.""" + if status_code is not None and status_code in _STATUS_FALLBACK_PROBLEMS: + return _STATUS_FALLBACK_PROBLEMS[status_code] + if status_code is None: + return _CLOUD_ERROR_PROBLEM + if status_code < HTTPStatus.INTERNAL_SERVER_ERROR: + return _REQUEST_REJECTED_PROBLEM + return _UNEXPECTED_PROBLEM + + +def describe_cloud_problem(problem: CloudProblem) -> tuple[str, str]: + """Return (message, guidance) for a parsed Cloud problem.""" + message, guidance = _PROBLEM_MESSAGES.get( + problem.slug or "", _status_fallback_message(problem.status_code) + ) + if problem.slug == "resource-not-found" and problem.resource_type: + message = f"The {problem.resource_type} was not found." + elif ( + problem.slug == "error:workspace-limit-for-organization-reached" + and problem.limit is not None + ): + message += f" (limit: {problem.limit} workspaces)" + if problem.status_code is not None: + message += ( + f" (Cloud error: {problem.slug}, HTTP {problem.status_code})" + if problem.slug + else f" (HTTP {problem.status_code})" + ) + if ( + problem.status_code is not None + and problem.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + and problem.error_id + ): + guidance += f" Error ID: {problem.error_id}." + return message, guidance + + 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: @@ -198,7 +616,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_problem(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, @@ -210,6 +633,7 @@ def _wrap_sdk_error( status_code = sdk_context.get("status_code") is_forbidden = status_code == HTTPStatus.FORBIDDEN + message, guidance = describe_cloud_problem(problem) error_type = ( AirbyteMissingResourceError if is_forbidden or status_code == HTTPStatus.NOT_FOUND @@ -220,26 +644,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("/") @@ -2008,9 +2419,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_problem(parse_cloud_problem(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." @@ -2357,15 +2768,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_problem(response.status_code, response.text) + message, guidance = describe_cloud_problem(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( @@ -2377,7 +2788,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 @@ -2443,18 +2855,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_problem(response.status_code, response.text) + message, guidance = describe_cloud_problem(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: @@ -2467,7 +2880,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 diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index fa2ec210e..eb223d7b7 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -4,11 +4,23 @@ from __future__ import annotations import json +import logging import re +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any -from fastmcp_extensions.otel._extras import _chain # noqa: PLC2701 +from airbyte_api.errors import SDKError +from fastmcp.exceptions import ToolError, ValidationError +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp_extensions.otel._arg_digests import classify_tool # noqa: PLC2701 +from fastmcp_extensions.otel._extras import _chain, declared_parameters # noqa: PLC2701 +from fastmcp_extensions.otel.models import TraceArg -from airbyte._util.api_util import error_response_body +from airbyte._util.api_util import ( + describe_cloud_problem, + error_response_body, + parse_cloud_problem, +) from airbyte.exceptions import ( AirbyteAgentsUnavailableError, AirbyteConnectionSyncError, @@ -25,6 +37,14 @@ ) +if TYPE_CHECKING: + from fastmcp.tools import Tool, ToolResult + from mcp import types as mt + + +logger = logging.getLogger(__name__) + + MCP_TOOL_USER_FACING_ERRORS: tuple[type[AirbyteLibError], ...] = ( AirbyteLibInputError, AirbyteMCPError, @@ -63,14 +83,6 @@ def classify_mcp_tool_error(error: BaseException) -> str | None: return "upstream_error" -_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") -# The last path segment of the generic problem `type` URL; `title` is the slug then. -_GENERIC_PROBLEM_SLUG = "errors" -# A slug names a kind of problem; one that is or holds an ID names a resource. -_ID_LIKE = re.compile(r"\d+|.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*") -_MAX_PROBLEM_BODY = 65536 - - def mcp_tool_error_reason(error: BaseException) -> str | None: """Return the problem slug from the first Airbyte API error body in the chain. @@ -81,23 +93,189 @@ def mcp_tool_error_reason(error: BaseException) -> str | None: (body for body in map(error_response_body, _chain(error)) if body is not None), None, ) - if body is None or len(body) > _MAX_PROBLEM_BODY: - return None - try: - problem = json.loads(body) - except (ValueError, RecursionError): - return None - if not isinstance(problem, dict): - return None - for field in ("type", "title"): - value = problem.get(field) - if not isinstance(value, str): - continue - slug = re.split(r"[/#]", value)[-1] - if ( - slug != _GENERIC_PROBLEM_SLUG - and _PROBLEM_SLUG.fullmatch(slug) - and not _ID_LIKE.fullmatch(slug) - ): - return slug - return None + return parse_cloud_problem(None, body).slug + + +def agent_error_text(error: ToolError) -> str: + """Return the text an agent sees for a tool error before redaction.""" + cause = error.__cause__ + if isinstance(cause, AirbyteLibError): + return format_user_facing_error(cause) + if isinstance(cause, SDKError): + message, guidance = describe_cloud_problem( + parse_cloud_problem(cause.status_code, cause.body) + ) + return f"{message} {guidance}" + return str(error) + + +def format_validation_error(error: ValidationError) -> str: + """Rebuild a FastMCP argument-validation error without `input_value` echoes.""" + cause = error.__cause__ + errors = getattr(cause, "errors", None) + parts: list[str] = [] + if callable(errors): + try: + details = errors(include_input=False, include_url=False, include_context=False) + except Exception: + details = [] + for detail in details if isinstance(details, list) else []: + msg = detail.get("msg") if isinstance(detail, Mapping) else None + loc = detail.get("loc", ()) if isinstance(detail, Mapping) else () + dotted = ".".join(str(part) for part in loc) + if msg and dotted: + parts.append(f"Invalid value for `{dotted}`: {msg}.") + elif msg: + parts.append(f"Invalid value: {msg}.") + return " ".join(parts) if parts else "Invalid arguments." + + +_JSON_LITERALS = frozenset({"true", "false", "null"}) +_INPUT_VALUE = re.compile(r"input_value=(?:'[^']*'|\"[^\"]*\"|[^\s,\])]+)") +_BEARER_TOKEN = re.compile(r"Bearer\s+[^\s'\")]+") +_URL_CREDENTIALS = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^\s/?#'\")]+@") +_URL_QUERY = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^\s?#'\")]*)\?[^\s'\")]*") + + +def _secret_leaf(leaf: str) -> bool: + """Whether a secret-class leaf is masked: everything but JSON literals.""" + return bool(leaf) and not ( + leaf in _JSON_LITERALS or (leaf.isdigit() and len(leaf) < 4) # noqa: PLR2004 + ) + + +def _collect_leaves( + value: Any, # noqa: ANN401 + path: str, + *, + secret: bool, + leaves: list[tuple[str, str]], +) -> None: + """Collect (placeholder path, leaf text) pairs for every value to hide.""" + if isinstance(value, bool): + return # `true`/`false` are JSON literals, never masked. + if isinstance(value, (int, float)): + leaf = str(value) + if (secret and _secret_leaf(leaf)) or (not secret and len(leaf) >= 4): # noqa: PLR2004 + leaves.append((path, leaf)) + return + if isinstance(value, str): + if (secret and _secret_leaf(value)) or (not secret and len(value) >= 4): # noqa: PLR2004 + leaves.append((path, value)) + # A JSON-string config may hold nested secrets; index its leaves too. + if value[:1] in {"{", "["}: + try: + nested = json.loads(value) + except ValueError: + return + if isinstance(nested, (dict, list)): + _collect_leaves(nested, path, secret=secret, leaves=leaves) + return + if isinstance(value, Mapping): + for key, item in value.items(): + if isinstance(key, str): + _collect_leaves(item, f"{path}.{key}", secret=secret, leaves=leaves) + return + if isinstance(value, (list, tuple)): + for item in value: + _collect_leaves(item, path, secret=secret, leaves=leaves) + + +def _allowed_choices( + tool: Tool | None, + name: str, + arg_class: Any, # noqa: ANN401 +) -> frozenset[Any]: + """Return the declared `enum`/`const` choices of a parameter, if any.""" + if tool is not None: + schema = declared_parameters(tool).get(name) + if isinstance(schema, Mapping): + if isinstance(schema.get("enum"), list): + return frozenset(schema["enum"]) + if "const" in schema: + return frozenset({schema["const"]}) + allowed = getattr(arg_class, "allowed", None) + return frozenset(allowed) if allowed else frozenset() + + +def redact_agent_text( + text: str, + arguments: Mapping[str, Any] | None, + tool: Tool | None, +) -> str: + """Mask every echoed argument value in agent-facing error text. + + Each parameter is classified by the same rules arg tracing uses, so error + text never shows a value tracing would not record in clear: secret-class + parameters lose every leaf, other parameters lose leaves of four or more + characters, and `VALUE` (enum-like) parameters stay clear only when they + hold one of the declared choices. + """ + if arguments: + func = getattr(tool, "fn", None) if tool is not None else None + try: + classes = classify_tool(func, sorted(arguments), tool=getattr(tool, "name", "")) + except Exception: + classes = {} + leaves: list[tuple[str, str]] = [] + for name, value in arguments.items(): + arg_class = classes.get(name) + mode = arg_class.mode if arg_class is not None else TraceArg.PRESENCE + if mode is TraceArg.VALUE: + if value in _allowed_choices(tool, name, arg_class): + continue + secret = False + else: + secret = mode in {TraceArg.OMIT, TraceArg.PRESENCE} + _collect_leaves(value, name, secret=secret, leaves=leaves) + seen: set[str] = set() + for path, leaf in sorted(leaves, key=lambda item: -len(item[1])): + if leaf in seen: + continue + seen.add(leaf) + text = text.replace(leaf, f"") + + text = _INPUT_VALUE.sub("input_value=", text) + text = _BEARER_TOKEN.sub("Bearer ", text) + text = _URL_CREDENTIALS.sub(r"\1@", text) + return _URL_QUERY.sub(r"\1", text) + + +async def _call_arguments( + context: MiddlewareContext[mt.CallToolRequestParams], +) -> tuple[Mapping[str, Any] | None, Tool | None]: + """Return the call's arguments and tool, either `None` when unavailable.""" + message = context.message + arguments = getattr(message, "arguments", None) + tool = None + fastmcp_context = context.fastmcp_context + if fastmcp_context is not None: + try: + tool = await fastmcp_context.fastmcp.get_tool(message.name) + except Exception: + logger.debug("Could not resolve the called tool for error redaction") + return arguments if isinstance(arguments, Mapping) else None, tool + + +class AgentErrorTextMiddleware(Middleware): + """Rewrite tool error text for the agent and mask echoed argument values. + + Runs outside `UserFacingErrorMiddleware` and telemetry: telemetry has + already classified the original exception when this middleware replaces it. + """ + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + try: + return await call_next(context) + except ValidationError as error: + arguments, tool = await _call_arguments(context) + raise ValidationError( + redact_agent_text(format_validation_error(error), arguments, tool) + ) from None + except ToolError as error: + arguments, tool = await _call_arguments(context) + raise ToolError(redact_agent_text(agent_error_text(error), arguments, tool)) from None diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 5e5950ffc..256b536b7 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -847,8 +847,8 @@ def create_connection_on_cloud( except AirbyteMissingResourceError as ex: raise AirbyteMissingResourceError( message=( - f"`{arg_name}` '{connector.connector_id}' was not found in workspace " - f"'{workspace.workspace_id}', or these credentials can't access it." + f"`{arg_name}` '{connector.connector_id}' was not found in the " + "configured workspace, or these credentials can't access it." ), guidance=api_util.FORBIDDEN_RESOURCE_GUIDANCE, resource_type=arg_name.removesuffix("_id"), diff --git a/airbyte/mcp/local.py b/airbyte/mcp/local.py index 8cb764387..a19ed019e 100644 --- a/airbyte/mcp/local.py +++ b/airbyte/mcp/local.py @@ -10,8 +10,8 @@ # tool / helper definitions as a redundant "API Documentation" list. __all__: list[str] = [] +import logging import sys -import traceback from itertools import islice from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Literal @@ -30,6 +30,7 @@ from airbyte.destinations.util import get_destination from airbyte.exceptions import AirbyteLibInputError from airbyte.mcp._arg_resolvers import resolve_connector_config, resolve_list_of_strings +from airbyte.mcp._error_handling import format_user_facing_error from airbyte.mcp._guards import raise_if_untrusted_execution_context from airbyte.mcp._tool_utils import mcp_tool from airbyte.registry import get_connector_metadata @@ -43,6 +44,9 @@ from airbyte.caches.duckdb import DuckDBCache +logger = logging.getLogger(__name__) + + _CONFIG_HELP = """ You can provide `config` as JSON or a Path to a YAML/JSON file. If a `dict` is provided, it must not contain hardcoded secrets. @@ -193,7 +197,9 @@ def validate_connector_config( manifest_path=manifest_path, ) except Exception as ex: - return False, f"Failed to get connector '{connector_name}': {ex}" + return False, ( + f"Failed to get connector '{connector_name}': " f"{format_user_facing_error(ex)}" + ) try: config_dict = resolve_connector_config( @@ -204,12 +210,17 @@ def validate_connector_config( ) source.set_config(config_dict) except Exception as ex: - return False, f"Failed to resolve configuration for {connector_name}: {ex}" + return False, ( + f"Failed to resolve configuration for {connector_name}: " + f"{format_user_facing_error(ex)}" + ) try: source.check() except Exception as ex: - return False, f"Configuration for {connector_name} is invalid: {ex}" + return False, ( + f"Configuration for {connector_name} is invalid: " f"{format_user_facing_error(ex)}" + ) return True, f"Configuration for {connector_name} is valid!" @@ -498,10 +509,11 @@ def read_source_stream_records( print(f"Retrieved {len(records)} records from stream '{stream_name}'", sys.stderr) except Exception as ex: - tb_str = traceback.format_exc() + logger.exception("Error reading records from source '%s'", source_connector_name) # If any error occurs, we print the error message to stderr and return an empty list. return ( - f"Error reading records from source '{source_connector_name}': {ex!r}, {ex!s}\n{tb_str}" + f"Error reading records from source '{source_connector_name}': " + f"{format_user_facing_error(ex)}" ) else: @@ -611,10 +623,10 @@ def get_stream_previews( on_error="ignore", ) except Exception as ex: - tb_str = traceback.format_exc() + logger.exception("Error getting stream previews from source '%s'", source_name) return { "ERROR": f"Error getting stream previews from source '{source_name}': " - f"{ex!r}, {ex!s}\n{tb_str}" + f"{format_user_facing_error(ex)}" } result: dict[str, list[dict[str, Any]] | str] = {} @@ -863,11 +875,10 @@ def run_sql_query( max_records=max_records, ) except Exception as ex: - tb_str = traceback.format_exc() + logger.exception("Error running SQL query") return [ { - "ERROR": f"Error running SQL query: {ex!r}, {ex!s}", - "TRACEBACK": tb_str, + "ERROR": f"Error running SQL query: {format_user_facing_error(ex)}", "SQL_QUERY": sql_query, } ] diff --git a/airbyte/mcp/server.py b/airbyte/mcp/server.py index 142839977..9a7d948f6 100644 --- a/airbyte/mcp/server.py +++ b/airbyte/mcp/server.py @@ -62,6 +62,7 @@ OIDCAuthConfig, TelemetryConfig, TelemetrySinks, + UserFacingErrorMiddleware, build_mcp_auth, mcp_server, ) @@ -82,6 +83,7 @@ from airbyte.mcp._config import load_secrets_to_env_vars from airbyte.mcp._error_handling import ( MCP_TOOL_USER_FACING_ERRORS, + AgentErrorTextMiddleware, format_user_facing_error, ) from airbyte.mcp._policy_middleware import PolicyGuardMiddleware @@ -571,6 +573,17 @@ async def _mcp_mode_lifespan( # noqa: RUF029 ) """The Airbyte MCP Server application instance.""" +# Rewrites and masks agent-facing error text; must run outside (before) the +# user-facing and telemetry middleware so they still see the original error. +app.middleware.insert( + next( + index + for index, middleware in enumerate(app.middleware) + if isinstance(middleware, UserFacingErrorMiddleware) + ), + AgentErrorTextMiddleware(), +) + app.add_middleware(ServerConnectedTelemetryMiddleware(lifecycle_telemetry_sinks)) app.add_middleware(PolicyGuardMiddleware()) app.middleware.insert(0, CallScopeMiddleware()) diff --git a/tests/unit_tests/test_cloud_api_util.py b/tests/unit_tests/test_cloud_api_util.py index cc36eae58..d16500659 100644 --- a/tests/unit_tests/test_cloud_api_util.py +++ b/tests/unit_tests/test_cloud_api_util.py @@ -115,7 +115,7 @@ def test_set_connection_interval_schedule_reports_api_failure() -> None: json={"message": "schedule rejected"}, status=400, ) - with pytest.raises(AirbyteCloudError, match="status 400"): + with pytest.raises(AirbyteCloudError, match="HTTP 400"): api_util.set_connection_interval_schedule( connection_id="connection-id", interval_hours=24, @@ -278,15 +278,17 @@ def _list_workspaces_response( pytest.param( 404, AirbyteMissingResourceError, - "API error occurred: Workspace lookup failed.", - None, + "The resource was not found. (HTTP 404)", + "Check the ID; list the resources to find the right one.", id="not_found", ), pytest.param( 500, AirbyteCloudError, - "API error occurred: Workspace lookup failed.", - None, + "Airbyte Cloud hit an unexpected error. This is often caused by an " + "invalid argument value. (HTTP 500)", + "Check the arguments (ranges, IDs, formats). If they look right, " + "don't retry more than once; tell the user.", id="server_error", ), ], @@ -1268,7 +1270,9 @@ def test_get_connection_fallback_404_raises_missing_resource_error() -> None: assert len(responses.calls) == 2 assert exc_info.value.context["status_code"] == 404 - assert exc_info.value.guidance is None + assert exc_info.value.guidance == ( + "Check the ID; list the resources to find the right one." + ) @responses.activate @@ -2099,8 +2103,10 @@ def test_update_connector_builder_project_payload( pytest.param( 500, AirbyteCloudError, - "API request failed with status 500", - None, + "Airbyte Cloud hit an unexpected error. This is often caused by an " + "invalid argument value. (HTTP 500)", + "Check the arguments (ranges, IDs, formats). If they look right, " + "don't retry more than once; tell the user.", id="server-error", ), ], @@ -2139,8 +2145,9 @@ def test_config_api_request_maps_forbidden_as_missing_resource( assert error.context["full_url"] == "https://config.airbyte.com/v1/workspaces/get" assert error.context["config_api_root"] == "https://config.airbyte.com/v1" assert error.context["url"] == response.request.url - assert error.context["body"] == response.request.body - assert error.context["response"] is response.__dict__ + assert "body" not in error.context + assert "response" not in error.context + assert error.context["problem_type"] is None assert isinstance(error.__cause__, requests.HTTPError) assert error.__cause__.response is response assert ( @@ -2292,10 +2299,10 @@ def _call_create_connection() -> None: ) -def test_create_connection_400_raises_input_error_with_api_message( +def test_create_connection_400_raises_input_error_without_api_message( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A 400 surfaces the API's `data.message` as an `AirbyteLibInputError`.""" + """A 400 raises `AirbyteLibInputError`; `data.message` is never relayed.""" api_message = ( "No streams found with name [no_such_stream] and namespace [null]. " "Please call the https://reference.airbyte.com/reference/getstreamproperties " @@ -2327,7 +2334,11 @@ def test_create_connection_400_raises_input_error_with_api_message( with pytest.raises(AirbyteLibInputError) as exc_info: _call_create_connection() - assert exc_info.value.get_message() == api_message + assert exc_info.value.get_message() == ( + "Airbyte Cloud rejected the request as invalid. " + "(Cloud error: bad-request, HTTP 400)" + ) + assert api_message not in str(exc_info.value) assert exc_info.value.context["source_id"] == "source-id" assert exc_info.value.context["destination_id"] == "destination-id" assert exc_info.value.context["status_code"] == 400 @@ -2362,7 +2373,7 @@ def test_create_connection_400_with_non_json_body_uses_fallback_message( _call_create_connection() assert exc_info.value.get_message() == ( - "The Airbyte API rejected the connection request (HTTP 400)." + "Airbyte Cloud rejected the request as invalid. (HTTP 400)" ) assert exc_info.value.__cause__ is sdk_error @@ -2692,3 +2703,273 @@ def test_get_source_reraises_non_404_sdk_error_as_airbyte_error( ) assert type(exc_info.value) is AirbyteCloudError + + +_INTERNAL_MARKER = "INTERNAL_MARKER select x from y" + + +def _sdk_error(status_code: int, body: str | None) -> SDKError: + """Create an SDKError like the Speakeasy SDK raises on a non-2xx status.""" + raw_response = requests.Response() + raw_response.status_code = status_code + raw_response.url = "https://api.airbyte.com/v1/resources/resource-id" + return SDKError( + f"API error occurred: Status {status_code}", status_code, body, raw_response + ) + + +def _problem_body( + problem_type: str | None = None, + title: str | None = None, + data: dict[str, Any] | None = None, + **extra: Any, +) -> str: + """Build a Cloud problem body like the public API returns.""" + return json.dumps({ + "type": problem_type, + "title": title, + "detail": _INTERNAL_MARKER, + "data": data, + **extra, + }) + + +@pytest.mark.parametrize( + ("status_code", "body", "expected_message", "expected_guidance", "problem_type"), + [ + pytest.param( + 400, + _problem_body( + "https://reference.airbyte.com/reference/errors#bad-request", + data={"message": _INTERNAL_MARKER}, + ), + "Airbyte Cloud rejected the request as invalid. " + "(Cloud error: bad-request, HTTP 400)", + "Check the arguments against the tool description.", + "bad-request", + id="bad-request", + ), + pytest.param( + 404, + _problem_body( + "https://reference.airbyte.com/reference/errors#resource-not-found", + data={"resourceType": "source", "resourceId": _INTERNAL_MARKER}, + ), + "The source was not found. (Cloud error: resource-not-found, HTTP 404)", + "Check the ID; list the resources to find the right one.", + "resource-not-found", + id="resource-not-found-with-type", + ), + pytest.param( + 401, + _problem_body( + "https://reference.airbyte.com/reference/errors#invalid-api-key" + ), + "Airbyte Cloud rejected the credentials. " + "(Cloud error: invalid-api-key, HTTP 401)", + "Ask the user to check the client ID/secret or token; don't retry.", + "invalid-api-key", + id="invalid-api-key", + ), + pytest.param( + 400, + _problem_body("error:cron-validation/invalid-expression"), + "The connection schedule is invalid. " + "(Cloud error: invalid-expression, HTTP 400)", + "Fix the schedule (cron expression, timezone, or frequency) and retry.", + "invalid-expression", + id="schedule-invalid", + ), + pytest.param( + 400, + _problem_body("error:mapper-validation/secret-not-found"), + "The mapper configuration is invalid. " + "(Cloud error: secret-not-found, HTTP 400)", + "Fix the mapper configuration and retry. " + "If a mapper secret is missing, resend the secret values.", + "secret-not-found", + id="mapper-secret-not-found", + ), + pytest.param( + 409, + _problem_body("error:tag-already-exists"), + "A resource with this name or membership already exists. " + "(Cloud error: error:tag-already-exists, HTTP 409)", + "Use the existing one or pick another name; don't retry as is.", + "error:tag-already-exists", + id="already-exists", + ), + pytest.param( + 503, + _problem_body( + "https://reference.airbyte.com/reference/errors", + title="service-unavailable", + ), + "Airbyte Cloud is temporarily unavailable. " + "(Cloud error: service-unavailable, HTTP 503)", + "Wait and retry once.", + "service-unavailable", + id="service-unavailable", + ), + pytest.param( + 429, + '{"message":"You are being rate limited."}', + "Airbyte Cloud is refusing requests from these credentials " + "(rate limited). (HTTP 429)", + "Stop calling Airbyte Cloud tools and tell the user; don't retry.", + None, + id="load-shed-429", + ), + pytest.param( + 401, + '{"message":"Unauthorized"}', + "Airbyte Cloud rejected the credentials. (HTTP 401)", + "Ask the user to check the client ID/secret or token; don't retry.", + None, + id="non-problem-401", + ), + pytest.param( + 408, + "not json", + "Airbyte Cloud timed out. The change may or may not have been " + "applied. (HTTP 408)", + "Check the resource's current state before retrying; don't blindly " + "retry a create.", + None, + id="timeout-fallback", + ), + pytest.param( + 409, + "not json", + "The resource is in a state that doesn't allow this operation " + "(for example, a job is running or already finished). (HTTP 409)", + "Check the resource's current status before retrying.", + None, + id="conflict-fallback", + ), + pytest.param( + 422, + "not json", + "Airbyte Cloud rejected the request as invalid. (HTTP 422)", + "Check the arguments against the tool description.", + None, + id="other-4xx-fallback", + ), + pytest.param( + 500, + _problem_body( + "https://reference.airbyte.com/reference/errors", + title="unexpected-problem", + data={"message": _INTERNAL_MARKER}, + ), + "Airbyte Cloud hit an unexpected error. This is often caused by an " + "invalid argument value. (Cloud error: unexpected-problem, HTTP 500)", + "Check the arguments (ranges, IDs, formats). If they look right, " + "don't retry more than once; tell the user.", + "unexpected-problem", + id="unexpected-problem-hides-message", + ), + pytest.param( + 500, + '{"errorId":"123e4567-e89b-42d3-a456-426614174000"}', + "Airbyte Cloud hit an unexpected error. This is often caused by an " + "invalid argument value. (HTTP 500)", + "Check the arguments (ranges, IDs, formats). If they look right, " + "don't retry more than once; tell the user. " + "Error ID: 123e4567-e89b-42d3-a456-426614174000.", + None, + id="error-id-relayed-for-5xx", + ), + pytest.param( + 500, + '{"errorId":"not-a-uuid"}', + "Airbyte Cloud hit an unexpected error. This is often caused by an " + "invalid argument value. (HTTP 500)", + "Check the arguments (ranges, IDs, formats). If they look right, " + "don't retry more than once; tell the user.", + None, + id="error-id-ignored-unless-uuid", + ), + pytest.param( + 500, + json.dumps({"type": "too-big", "x": "y" * 70000}), + "Airbyte Cloud hit an unexpected error. This is often caused by an " + "invalid argument value. (HTTP 500)", + "Check the arguments (ranges, IDs, formats). If they look right, " + "don't retry more than once; tell the user.", + None, + id="oversized-body-falls-back", + ), + ], +) +def test_wrap_sdk_error_describes_cloud_problem( + status_code: int, + body: str, + expected_message: str, + expected_guidance: str, + problem_type: str | None, +) -> None: + """Each Cloud problem maps to its fixed message; `data.message` never relays.""" + wrapped = api_util._wrap_sdk_error(_sdk_error(status_code, body)) + + assert wrapped.get_message() == expected_message + assert wrapped.guidance == expected_guidance + assert wrapped.context["problem_type"] == problem_type + assert wrapped.context["status_code"] == status_code + assert _INTERNAL_MARKER not in wrapped.get_message() + assert _INTERNAL_MARKER not in (wrapped.guidance or "") + + +def test_wrap_sdk_error_403_keeps_fixed_forbidden_text() -> None: + wrapped = api_util._wrap_sdk_error( + _sdk_error( + 403, + _problem_body( + "https://reference.airbyte.com/reference/errors#forbidden", + data={"message": _INTERNAL_MARKER}, + ), + ) + ) + + assert type(wrapped) is AirbyteMissingResourceError + assert wrapped.get_message() == ( + "The requested resource was not found, or these credentials can't " + "access it (HTTP 403)." + ) + assert wrapped.guidance == api_util.FORBIDDEN_RESOURCE_GUIDANCE + assert wrapped.context["problem_type"] == "forbidden" + assert _INTERNAL_MARKER not in str(wrapped) + + +def test_public_api_json_error_context_hides_body_and_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Raw-HTTP helpers keep URL/status/slug in context, never bodies.""" + response = requests.Response() + response.status_code = 400 + response.url = "https://api.airbyte.com/v1/connections/connection-id" + response._content = json.dumps({ + "type": "https://reference.airbyte.com/reference/errors#bad-request", + "data": {"message": _INTERNAL_MARKER}, + }).encode() + response.request = requests.Request("GET", response.url).prepare() + request = Mock(return_value=response) + monkeypatch.setattr(api_util.requests, "get", request) + monkeypatch.setattr(api_util, "get_bearer_token", lambda **_: SecretString("token")) + + with pytest.raises(AirbyteCloudError) as exc_info: + api_util._get_public_api_json( + api_root="https://api.airbyte.com/v1", + path="/connections/connection-id", + params=None, + client_id=None, + client_secret=None, + bearer_token=SecretString("token"), + ) + + error = exc_info.value + assert "body" not in error.context + assert "response" not in error.context + assert error.context["problem_type"] == "bad-request" + assert error.context["url"] == response.request.url + assert _INTERNAL_MARKER not in str(error) diff --git a/tests/unit_tests/test_connector_base.py b/tests/unit_tests/test_connector_base.py new file mode 100644 index 000000000..42f7a7fac --- /dev/null +++ b/tests/unit_tests/test_connector_base.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Unit tests for connector-base config validation error text.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from airbyte.exceptions import AirbyteConnectorValidationFailedError +from airbyte.sources.base import Source + + +SECRET_VALUE = "sk_live_FAKE1234567890" + + +def test_validate_config_never_repeats_a_hydrated_secret() -> None: + """A jsonschema failure quotes no hydrated config value anywhere.""" + spec = SimpleNamespace( + connectionSpecification={ + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "api_key": {"type": "string", "pattern": "^pk_"}, + }, + }, + }, + } + ) + source = Source(executor=Mock(), name="test-source") + source._spec = spec + source.set_config({"credentials": {"api_key": SECRET_VALUE}}, validate=False) + + with pytest.raises(AirbyteConnectorValidationFailedError) as exc_info: + source.validate_config() + + error = exc_info.value + assert "not valid" in error.get_message() + assert "credentials.api_key" in error.get_message() + assert SECRET_VALUE not in str(error) + assert SECRET_VALUE not in error.get_message() + assert SECRET_VALUE not in str(error.context) diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 809b17110..3fcb2ce7e 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -1351,3 +1351,24 @@ def native_arg_tool( assert metadata["arg.prompt"] == tags["airbyte.mcp.arg.prompt"] assert "arg.forged" not in metadata assert "private" not in json.dumps([tags, metadata]) + + +def test_agent_error_text_sits_outside_user_facing_and_telemetry_middleware() -> None: + from fastmcp.server.middleware import Middleware + from fastmcp_extensions import UserFacingErrorMiddleware + + from airbyte.mcp import server as real_server + from airbyte.mcp._error_handling import AgentErrorTextMiddleware + + kinds = [type(middleware) for middleware in real_server.app.middleware] + agent_index = kinds.index(AgentErrorTextMiddleware) + user_facing_index = kinds.index(UserFacingErrorMiddleware) + assert agent_index < user_facing_index + telemetry_indexes = [ + index + for index, middleware in enumerate(real_server.app.middleware) + if index != agent_index + and isinstance(middleware, Middleware) + and "telemetry" in type(middleware).__name__.lower() + ] + assert all(index > agent_index for index in telemetry_indexes) diff --git a/tests/unit_tests/test_mcp_otel.py b/tests/unit_tests/test_mcp_otel.py index 5b4c831bf..6d5332c1f 100644 --- a/tests/unit_tests/test_mcp_otel.py +++ b/tests/unit_tests/test_mcp_otel.py @@ -12,6 +12,7 @@ import pytest from fastmcp import Client, FastMCP +from fastmcp.exceptions import ToolError from fastmcp_extensions import ( TelemetryConfig, ToolCallTelemetryMiddleware, @@ -20,6 +21,7 @@ mcp_server, register_tool_call_telemetry, ) +from fastmcp_extensions import UserFacingErrorMiddleware from opentelemetry import trace from opentelemetry.instrumentation.requests import RequestsInstrumentor from opentelemetry.sdk.trace import ReadableSpan, TracerProvider @@ -28,9 +30,16 @@ from opentelemetry.util.types import AttributeValue from airbyte._util import meta +from airbyte.exceptions import AirbyteConnectionSyncError from airbyte.constants import CLOUD_API_ROOT from airbyte.mcp import _otel as observability -from airbyte.mcp._error_handling import classify_mcp_tool_error, mcp_tool_error_reason +from airbyte.mcp._error_handling import ( + MCP_TOOL_USER_FACING_ERRORS, + AgentErrorTextMiddleware, + classify_mcp_tool_error, + format_user_facing_error, + mcp_tool_error_reason, +) from airbyte.mcp import _scope from airbyte.mcp._scope import ( CallScopeMiddleware, @@ -492,3 +501,221 @@ def test_argument_hmac_key_validation_is_private_and_warns_once( assert observability._arg_key() is None assert caplog.text.count("AIRBYTE_MCP_TELEMETRY_HMAC_KEY is invalid") == 1 assert "private-invalid-key!" not in caplog.text + + +# -- T6: error telemetry facts are unchanged by the agent-facing text layer ---- +# +# Expected values captured on the pre-change base commit (d74eeb30) by running +# the same scenario; the new middleware is outermost so classification sees the +# same innermost cause. + + +def _wrapped_sdk_error(status: int, body: str) -> tuple[Exception, Exception]: + import requests + from airbyte_api.errors import SDKError + + from airbyte._util import api_util + + raw = requests.Response() + raw.status_code = status + raw.url = "https://api.airbyte.com/v1/x" + sdk = SDKError("API error occurred", status, body, raw) + return sdk, api_util._wrap_sdk_error(sdk) + + +_ERROR_CASES: dict[str, tuple[Exception | None, Exception]] = { + "forbidden": _wrapped_sdk_error( + 403, '{"type":"https://reference.airbyte.com/reference/errors#forbidden"}' + ), + "bad_request": _wrapped_sdk_error( + 400, '{"type":"https://reference.airbyte.com/reference/errors#bad-request"}' + ), + "load_shed": _wrapped_sdk_error(429, '{"message":"You are being rate limited."}'), + "unexpected": _wrapped_sdk_error( + 500, + '{"type":"https://reference.airbyte.com/reference/errors",' + '"title":"unexpected-problem"}', + ), + "unavailable": _wrapped_sdk_error(503, '{"type":"error:service-unavailable"}'), + "not_ready": _wrapped_sdk_error( + 409, + '{"type":"https://reference.airbyte.com/reference/errors#409-actor-not-ready"}', + ), + "sync_failed": ( + None, + AirbyteConnectionSyncError(connection_id="c", job_id=1, job_status="failed"), + ), + "runtime": (None, RuntimeError("boom with SECRET-ARG-VALUE")), +} + + +@pytest.mark.parametrize( + ("case", "expected"), + [ + pytest.param( + "forbidden", + { + "airbyte.mcp.error.category": "user_error", + "airbyte.mcp.error.fault": "caller", + "airbyte.mcp.upstream.status_code": 403, + "airbyte.mcp.error.reason": "forbidden", + }, + id="forbidden", + ), + pytest.param( + "bad_request", + { + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": 400, + "airbyte.mcp.error.reason": "bad-request", + }, + id="bad-request", + ), + pytest.param( + "load_shed", + { + "airbyte.mcp.error.category": "rate_limited", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": 429, + }, + id="load-shed", + ), + pytest.param( + "unexpected", + { + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": 500, + "airbyte.mcp.error.reason": "unexpected-problem", + }, + id="unexpected", + ), + pytest.param( + "unavailable", + { + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + "airbyte.mcp.upstream.status_code": 503, + "airbyte.mcp.error.reason": "error:service-unavailable", + }, + id="unavailable", + ), + pytest.param( + "not_ready", + { + "airbyte.mcp.error.category": "user_error", + "airbyte.mcp.error.fault": "caller", + "airbyte.mcp.upstream.status_code": 409, + "airbyte.mcp.error.reason": "409-actor-not-ready", + }, + id="not-ready", + ), + pytest.param( + "sync_failed", + { + "airbyte.mcp.error.category": "upstream_error", + "airbyte.mcp.error.fault": "upstream", + }, + id="sync-failed", + ), + pytest.param( + "runtime", + { + "airbyte.mcp.error.category": "unclassified", + "airbyte.mcp.error.fault": "unknown", + }, + id="runtime", + ), + pytest.param( + "bad_arg", + { + "airbyte.mcp.error.category": "invalid_arguments", + "airbyte.mcp.error.fault": "caller", + }, + id="bad-arg", + ), + ], +) +def test_error_telemetry_facts_unchanged( + case: str, expected: Mapping[str, object] +) -> None: + async def scenario() -> None: + app = mcp_server( + display_name="t6", + telemetry=TelemetryConfig(package_name="airbyte"), + user_facing_errors=MCP_TOOL_USER_FACING_ERRORS, + user_facing_error_formatter=format_user_facing_error, + ) + app.middleware.insert( + next( + index + for index, middleware in enumerate(app.middleware) + if isinstance(middleware, UserFacingErrorMiddleware) + ), + AgentErrorTextMiddleware(), + ) + + @app.tool() + def tool() -> str: + if case == "bad_arg": + return "x" + cause, wrapped = _ERROR_CASES[case] + if cause is not None: + raise wrapped from cause + raise wrapped + + @app.tool() + def bad_arg_tool(count: int) -> str: + return "x" + + register_tool_call_telemetry( + app, + TelemetryConfig( + package_name="airbyte", + tool_tracing=ToolTracingConfig( + attribute_prefix="airbyte.mcp", + error_classifier=classify_mcp_tool_error, + error_reason=mcp_tool_error_reason, + ), + ), + ) + + with capture_tool_spans() as spans: + async with Client(app) as client: + with pytest.raises(ToolError): + if case == "bad_arg": + await client.call_tool( + "bad_arg_tool", {"count": "sk_live_FAKE1234567890"} + ) + else: + await client.call_tool("tool") + + tool_name = "bad_arg_tool" if case == "bad_arg" else "tool" + attributes = _tool_span(spans, tool_name).attributes or {} + for key, value in expected.items(): + assert attributes.get(key) == value, f"{key}: {attributes.get(key)}" + if "airbyte.mcp.error.reason" not in expected: + assert "airbyte.mcp.error.reason" not in attributes + + asyncio.run(scenario()) + + +def test_agent_error_text_sits_outside_user_facing_and_telemetry_middleware() -> None: + from fastmcp_extensions import ( + ToolCallTelemetryMiddleware, + UserFacingErrorMiddleware, + ) + + from airbyte.mcp import server as real_server + from airbyte.mcp._error_handling import AgentErrorTextMiddleware + + kinds = [type(middleware) for middleware in real_server.app.middleware] + agent_index = kinds.index(AgentErrorTextMiddleware) + assert agent_index < kinds.index(UserFacingErrorMiddleware) + telemetry_indexes = [ + index + for index, middleware in enumerate(real_server.app.middleware) + if isinstance(middleware, ToolCallTelemetryMiddleware) + ] + assert telemetry_indexes and all(index > agent_index for index in telemetry_indexes) diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index 89a4b5f0d..6cb9a82e5 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -5,11 +5,15 @@ import asyncio import json +from typing import Literal import pytest +import requests +from airbyte_api.errors import SDKError from fastmcp import Client from fastmcp.exceptions import ToolError +from airbyte._util import api_util from airbyte.exceptions import ( AirbyteCloudApiError, AirbyteConnectionSyncError, @@ -21,11 +25,13 @@ ) from airbyte.mcp._error_handling import ( MCP_TOOL_USER_FACING_ERRORS, + AgentErrorTextMiddleware, classify_mcp_tool_error, format_user_facing_error, mcp_tool_error_reason, ) -from fastmcp_extensions import mcp_server +from airbyte.mcp._tool_utils import check_guid_created_in_session +from fastmcp_extensions import UserFacingErrorMiddleware, mcp_server def test_unexpected_errors_keep_fastmcp_default_handling() -> None: @@ -171,3 +177,143 @@ def test_mcp_tool_error_reason_is_never_an_id( ) -> None: error = AirbyteCloudApiError(context={"response_text": json.dumps(problem)}) assert mcp_tool_error_reason(error) == expected + + +def _server_with_agent_error_text(): + """Build a test server wired like `airbyte.mcp.server.app`.""" + server = mcp_server( + "test", + telemetry=False, + user_facing_errors=MCP_TOOL_USER_FACING_ERRORS, + user_facing_error_formatter=format_user_facing_error, + ) + server.middleware.insert( + next( + i + for i, middleware in enumerate(server.middleware) + if isinstance(middleware, UserFacingErrorMiddleware) + ), + AgentErrorTextMiddleware(), + ) + return server + + +def _call_error(server, name: str, arguments: dict | None = None) -> str: + async def call_tool() -> str: + async with Client(server) as client: + try: + await client.call_tool(name, arguments or {}) + except ToolError as error: + return str(error) + raise AssertionError("Expected ToolError") + + return asyncio.run(call_tool()) + + +def test_argument_errors_name_the_parameter_without_the_value() -> None: + server = _server_with_agent_error_text() + + @server.tool + def typed(count: int) -> str: + return "ok" + + text = _call_error(server, "typed", {"count": "sk_live_FAKE1234567890"}) + + assert "sk_live_FAKE1234567890" not in text + assert "input_value" not in text + assert "`count`" in text + + +def test_enum_typo_is_masked_with_parameter_name() -> None: + server = _server_with_agent_error_text() + + @server.tool + def with_mode(mode: Literal["full", "incremental"]) -> str: + return mode + + text = _call_error(server, "with_mode", {"mode": "incrimental-typo-value"}) + + assert "incrimental-typo-value" not in text + assert "input_value" not in text + + +def test_cloud_error_is_formatted_without_debug_context() -> None: + server = _server_with_agent_error_text() + + @server.tool + def broken() -> str: + raw = requests.Response() + raw.status_code = 500 + raw.url = "https://api.airbyte.com/v1/connections" + body = json.dumps({ + "type": "https://reference.airbyte.com/reference/errors", + "title": "unexpected-problem", + "data": {"message": "INTERNAL_MARKER select x from y"}, + }) + error = SDKError("API error occurred", 500, body, raw) + raise api_util._wrap_sdk_error(error) from error + + text = _call_error(server, "broken") + + assert "Airbyte Cloud hit an unexpected error" in text + assert "unexpected-problem, HTTP 500" in text + assert "INTERNAL_MARKER" not in text + assert "Status Code" not in text + assert "api.airbyte.com" not in text + + +def test_unexpected_error_echoed_argument_is_masked() -> None: + server = _server_with_agent_error_text() + + @server.tool + def fail(connection_id: str) -> str: + raise RuntimeError(f"boom with {connection_id}") + + text = _call_error(server, "fail", {"connection_id": "SENTINEL-arg-value-1234"}) + + assert "boom with " in text + assert "SENTINEL-arg-value-1234" not in text + + +def test_user_facing_error_echoed_id_is_masked() -> None: + server = _server_with_agent_error_text() + + @server.tool + def unsafe(connection_id: str) -> str: + check_guid_created_in_session(connection_id) + return "done" + + text = _call_error( + server, + "unsafe", + {"connection_id": "00000000-1111-2222-3333-444444444444"}, + ) + + assert "Cannot perform destructive operation" in text + assert "00000000-1111-2222-3333-444444444444" not in text + assert "" in text + + +def test_secret_valued_argument_never_shows_a_prefix() -> None: + server = _server_with_agent_error_text() + + @server.tool + def read_records(config: dict | str | None, stream_name: str) -> str: + raise AirbyteLibInputError( + message=f"Stream '{stream_name}' failed with key {config}" + ) + + secret = "sk_live_FAKE1234567890" + text = _call_error( + server, + "read_records", + { + "config": {"api_key": secret}, + "stream_name": "stream_with_secret_value", + }, + ) + + assert secret not in text + assert "sk_live" not in text + assert "" in text + assert "stream_with_secret_value" not in text From a16d535ab81cbc9e2723a26e1342c94c037e2676 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 04:27:26 +0000 Subject: [PATCH 11/22] fix(mcp): match Cloud problem types in every published format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_util/api_util.py | 189 +++++++++------ tests/unit_tests/test_cloud_api_util.py | 310 +++++++++++++++++++++++- 2 files changed, 419 insertions(+), 80 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 37b919c3f..c0b9bb683 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -192,6 +192,7 @@ class CloudProblem: status_code: int | None slug: str | None # Last `type`/`title` segment, like `mcp_tool_error_reason`. + key: str | None # Normalized `type` path used to look up the fixed message. resource_type: str | None # `data.resourceType`, a fixed word. limit: int | None # `data.limit`. error_id: str | None # Top-level `errorId` of a non-problem body. @@ -213,12 +214,37 @@ def _problem_slug(problem: dict[str, Any]) -> str | None: return None +_PROBLEM_TYPE_PREFIX = "https://reference.airbyte.com/reference/errors" + + +def _problem_key(problem: dict[str, Any]) -> str | None: + """Return the normalized `type` path used to look up the fixed message. + + Cloud emits `type` as the bare errors URL (slug in `title`), as the URL + plus `#`, or as `error:`; all three normalize to the same key. + """ + value = problem.get("type") + key = value if isinstance(value, str) else "" + if key.startswith(_PROBLEM_TYPE_PREFIX): + key = key.removeprefix(_PROBLEM_TYPE_PREFIX) + if key[:1] in {"#", "/"}: + key = key[1:] + if key.startswith("error:"): + key = key.removeprefix("error:") + if key.startswith("409-"): + key = key.removeprefix("409-") + if not key: + title = problem.get("title") + key = title if isinstance(title, str) else "" + return key or None + + def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProblem: """Read only the allow-listed fields of a Cloud API error body. Bodies over 64 KiB or non-JSON give `slug=None`. """ - slug = resource_type = error_id = None + slug = key = resource_type = error_id = None limit = None if body is not None and len(body) <= _MAX_PROBLEM_BODY: try: @@ -227,6 +253,7 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl problem = None if isinstance(problem, dict): slug = _problem_slug(problem) + key = _problem_key(problem) data = problem.get("data") if isinstance(data, dict): candidate = data.get("resourceType") @@ -241,6 +268,7 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl return CloudProblem( status_code=status_code, slug=slug, + key=key, resource_type=resource_type, limit=limit, error_id=error_id, @@ -325,54 +353,52 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl "Retry at most once; tell the user if it persists.", ) -# Each key is the problem slug: the last `type` path segment (or `title` when the -# type is the generic errors page). `notification/config/required` and -# `connection/destination-catalog/required` collide on `required`, which takes -# the generic rejected-value wording. +# Each key is the normalized `type` path (`error:` -> ``, +# `.../errors#` -> ``, bare errors URL -> `title`). _PROBLEM_MESSAGES: dict[str, tuple[str, str]] = { "resource-not-found": _RESOURCE_NOT_FOUND_PROBLEM, - "catalog-not-found": ( + "destination/catalog-not-found": ( "This connection has no destination catalog yet.", "Set up the destination catalog before this operation; do not change the ID.", ), - "subscription-required": ( + "billing/subscription/subscription-required": ( "This organization needs an active Airbyte subscription for this operation.", "Ask the user to check billing; don't retry.", ), - "no-active-subscription": ( + "billing/no-active-subscription": ( "This organization needs an active Airbyte subscription for this operation.", "Ask the user to check billing; don't retry.", ), - "insufficient-payment-status": ( + "billing/insufficient-payment-status": ( "The organization's payment status or credit balance doesn't allow this.", "Ask the user to resolve billing; don't retry.", ), - "insufficient-credit-balance": ( + "billing/insufficient-credit-balance": ( "The organization's payment status or credit balance doesn't allow this.", "Ask the user to resolve billing; don't retry.", ), "invalid-api-key": _INVALID_API_KEY_PROBLEM, - "error:sso-token-validation": ( + "sso-token-validation": ( "The SSO access token is invalid or expired.", "Ask the user to sign in again.", ), - "sso-required": ( + "auth/sso-required": ( "This email domain must sign in with SSO.", "Ask the user to sign in through SSO.", ), - "invalid-github-token": ( + "generate-contribution/invalid-github-token": ( "The GitHub token is invalid or lacks repo write permission.", "Ask the user for a valid token.", ), - "insufficient-github-token-permissions": ( + "generate-contribution/insufficient-github-token-permissions": ( "The GitHub token is invalid or lacks repo write permission.", "Ask the user for a valid token.", ), - "entitlement": ( + "license/entitlement": ( "The organization's plan doesn't allow this.", "Ask the user; don't retry.", ), - "error:workspace-limit-for-organization-reached": ( + "workspace-limit-for-organization-reached": ( "The organization's plan doesn't allow this.", "Ask the user; don't retry.", ), @@ -389,8 +415,8 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl "The redirect URL is not a valid HTTPS URL.", "Fix the configuration and retry.", ), - "409-state-conflict": _STATE_CONFLICT_PROBLEM, - "locked": ( + "state-conflict": _STATE_CONFLICT_PROBLEM, + "connection/locked": ( "The connection is locked and can't be changed right now.", "Don't retry; ask the user.", ), @@ -398,29 +424,29 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl "Airbyte Cloud is busy with a conflicting change.", "Wait and retry once.", ), - "error:group-managed-by-scim": ( + "group-managed-by-scim": ( "This group is managed by SCIM and can't be changed here.", "Don't retry; tell the user.", ), - "runtime-secrets-manager-required": ( + "mapper-validation/runtime-secrets-manager-required": ( "This needs a runtime secrets manager configured.", "Ask the user.", ), - "error:failed-precondition": ( + "failed-precondition": ( "A required condition for this operation isn't met.", "Check the resource's state; ask the user if unclear.", ), - "error:request-timeout-exceeded": _REQUEST_TIMEOUT_PROBLEM, + "request-timeout-exceeded": _REQUEST_TIMEOUT_PROBLEM, "unexpected-problem": _UNEXPECTED_PROBLEM, "service-unavailable": ( "Airbyte Cloud is temporarily unavailable.", "Wait and retry once.", ), - "error-adding-organization": ( + "entitlement-service/error-adding-organization": ( "Airbyte Cloud is temporarily unavailable.", "Wait and retry once.", ), - "error:tag-limit-for-workspace-reached": ( + "tag-limit-for-workspace-reached": ( "The workspace has reached its tag limit.", "Delete a tag first or ask the user.", ), @@ -428,12 +454,13 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "missing-cron-data", - "missing-component", - "unsupported-timezone", - "invalid-expression", - "invalid-timezone", - "under-one-hour-not-allowed", + "cron-validation/missing-cron-data", + "cron-validation/missing-component", + "cron-validation/unsupported-timezone", + "cron-validation/invalid-expression", + "cron-validation/invalid-timezone", + "cron-validation/under-one-hour-not-allowed", + "basic-schedule-validation/under-one-hour-not-allowed", ), _SCHEDULE_INVALID_PROBLEM, ) @@ -441,10 +468,10 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "error:mapper-validation", - "missing-required-param", - "invalid-config", - "secret-not-found", + "mapper-validation", + "mapper-validation/missing-required-param", + "mapper-validation/invalid-config", + "mapper-validation/secret-not-found", ), _MAPPER_INVALID_PROBLEM, ) @@ -452,9 +479,9 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "connection-unsupported", - "stream-unsupported", - "error:connection-conflicting-destination-stream", + "connection-validation/file-transfer/connection-unsupported", + "connection-validation/file-transfer/stream-unsupported", + "connection-conflicting-destination-stream", ), _STREAM_SELECTION_PROBLEM, ) @@ -462,13 +489,14 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "missing-object-name", - "invalid-operation", - "missing-required-field", - "invalid-additional-field", - "missing-primary-key", - "invalid-primary-key", - "discover-not-supported", + "connection/destination-catalog/missing-object-name", + "connection/destination-catalog/invalid-operation", + "connection/destination-catalog/missing-required-field", + "connection/destination-catalog/invalid-additional-field", + "connection/destination-catalog/required", + "connection/destination-catalog/missing-primary-key", + "connection/destination-catalog/invalid-primary-key", + "destination/discover-not-supported", ), _DESTINATION_CATALOG_PROBLEM, ) @@ -476,31 +504,35 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "error:tag-invalid-hex-color", - "error:tag-name-too-long", - "required", - "missing-url", + "tag-invalid-hex-color", + "tag-name-too-long", + "notification/config/required", + "notification/config/missing-url", ), _VALUE_REJECTED_PROBLEM, ) ) _PROBLEM_MESSAGES.update( dict.fromkeys( - ("invalid-request", "rollout-percentage-reached", "not-enough-actors"), + ( + "connector-rollout/invalid-request", + "connector-rollout/rollout-percentage-reached", + "connector-rollout/not-enough-actors", + ), _ROLLOUT_REJECTED_PROBLEM, ) ) _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "user-already-exists", - "error:tag-already-exists", - "error:group-already-exists", - "error:group-permission-already-exists", - "error:group-member-already-exists", - "error:dataplane-group-name-already-exists", - "error:dataplane-name-already-exists", - "connector-image-name-in-use", + "auth/user-already-exists", + "tag-already-exists", + "group-already-exists", + "group-permission-already-exists", + "group-member-already-exists", + "dataplane-group-name-already-exists", + "dataplane-name-already-exists", + "generate-contribution/connector-image-name-in-use", ), _ALREADY_EXISTS_PROBLEM, ) @@ -510,29 +542,35 @@ def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProbl ( "oauth-callback-failure", "invalid-consent-url", - "error:generate-contribution", - "error:sso-config-retrieval", - "error:sso-setup", - "error:sso-deletion", - "error:sso-credential-update", - "error:sso-activation", - "invalid-organization-state", + "generate-contribution", + "sso-config-retrieval", + "sso-setup", + "sso-deletion", + "sso-credential-update", + "sso-activation", + "entitlement-service/invalid-organization-state", ), _CLOUD_OPERATION_FAILED_PROBLEM, ) ) _PROBLEM_MESSAGES.update( - dict.fromkeys(("access-denied", "paid-plan-required", "generic"), _DBTCLOUD_PROBLEM) + dict.fromkeys( + ("dbtcloud/access-denied", "dbtcloud/paid-plan-required", "dbtcloud/generic"), + _DBTCLOUD_PROBLEM, + ) ) _PROBLEM_MESSAGES.update( - dict.fromkeys(("endpoint-moved", "not-implemented-in-oss"), _ENDPOINT_UNAVAILABLE_PROBLEM) + dict.fromkeys( + ("embedded/endpoint-moved", "implementation/not-implemented-in-oss"), + _ENDPOINT_UNAVAILABLE_PROBLEM, + ) ) _PROBLEM_MESSAGES.update( dict.fromkeys( ( - "no-cancelable-subscription", - "no-scheduled-cancellation-subscription", - "no-scheduled-plan-change", + "billing/no-cancelable-subscription", + "billing/no-scheduled-cancellation-subscription", + "billing/no-scheduled-plan-change", ), ( "There is no subscription change of this kind to act on.", @@ -565,19 +603,16 @@ def _status_fallback_message(status_code: int | None) -> tuple[str, str]: def describe_cloud_problem(problem: CloudProblem) -> tuple[str, str]: """Return (message, guidance) for a parsed Cloud problem.""" message, guidance = _PROBLEM_MESSAGES.get( - problem.slug or "", _status_fallback_message(problem.status_code) + problem.key or "", _status_fallback_message(problem.status_code) ) - if problem.slug == "resource-not-found" and problem.resource_type: + if problem.key == "resource-not-found" and problem.resource_type: message = f"The {problem.resource_type} was not found." - elif ( - problem.slug == "error:workspace-limit-for-organization-reached" - and problem.limit is not None - ): + elif problem.key == "workspace-limit-for-organization-reached" and problem.limit is not None: message += f" (limit: {problem.limit} workspaces)" if problem.status_code is not None: message += ( - f" (Cloud error: {problem.slug}, HTTP {problem.status_code})" - if problem.slug + f" (Cloud error: {problem.key}, HTTP {problem.status_code})" + if problem.key else f" (HTTP {problem.status_code})" ) if ( diff --git a/tests/unit_tests/test_cloud_api_util.py b/tests/unit_tests/test_cloud_api_util.py index d16500659..26a6134da 100644 --- a/tests/unit_tests/test_cloud_api_util.py +++ b/tests/unit_tests/test_cloud_api_util.py @@ -2775,7 +2775,7 @@ def _problem_body( 400, _problem_body("error:cron-validation/invalid-expression"), "The connection schedule is invalid. " - "(Cloud error: invalid-expression, HTTP 400)", + "(Cloud error: cron-validation/invalid-expression, HTTP 400)", "Fix the schedule (cron expression, timezone, or frequency) and retry.", "invalid-expression", id="schedule-invalid", @@ -2784,7 +2784,7 @@ def _problem_body( 400, _problem_body("error:mapper-validation/secret-not-found"), "The mapper configuration is invalid. " - "(Cloud error: secret-not-found, HTTP 400)", + "(Cloud error: mapper-validation/secret-not-found, HTTP 400)", "Fix the mapper configuration and retry. " "If a mapper secret is missing, resend the secret values.", "secret-not-found", @@ -2794,7 +2794,7 @@ def _problem_body( 409, _problem_body("error:tag-already-exists"), "A resource with this name or membership already exists. " - "(Cloud error: error:tag-already-exists, HTTP 409)", + "(Cloud error: tag-already-exists, HTTP 409)", "Use the existing one or pick another name; don't retry as is.", "error:tag-already-exists", id="already-exists", @@ -2973,3 +2973,307 @@ def test_public_api_json_error_context_hides_body_and_response( assert error.context["problem_type"] == "bad-request" assert error.context["url"] == response.request.url assert _INTERNAL_MARKER not in str(error) + + +# Every (status, type, title) default published in problems-api's +# api-problems.yaml, hard-coded so the test does not read the platform repo. +_PROBLEM_TYPE_DEFAULTS: list[tuple[int, str, str]] = [ + ( + 401, + "https://reference.airbyte.com/reference/errors#invalid-api-key", + "invalid-api-key", + ), + (403, "https://reference.airbyte.com/reference/errors#forbidden", "forbidden"), + (403, "error:auth/sso-required", "SSO Sign-in Required"), + (301, "error:embedded/endpoint-moved", "Airbyte Embedded Endpoint Moved"), + (403, "error:license/entitlement", "License Entitlement Error"), + (409, "error:auth/user-already-exists", "User already exists"), + (412, "error:failed-precondition", "Failed Precondition"), + ( + 500, + "https://reference.airbyte.com/reference/errors#oauth-callback-failure", + "oauth-callback-failure", + ), + ( + 500, + "https://reference.airbyte.com/reference/errors#invalid-consent-url", + "invalid-consent-url", + ), + ( + 422, + "https://reference.airbyte.com/reference/errors#invalid-redirect-url", + "invalid-redirect-url", + ), + ( + 422, + "https://reference.airbyte.com/reference/errors#unprocessable-entity", + "unprocessable-entity", + ), + (400, "https://reference.airbyte.com/reference/errors", "value-not-found"), + ( + 409, + "https://reference.airbyte.com/reference/errors#409-state-conflict", + "state-conflict", + ), + ( + 409, + "https://reference.airbyte.com/reference/errors#409-actor-not-ready", + "actor-not-ready", + ), + (409, "error:connection/locked", "Connection is locked"), + ( + 409, + "https://reference.airbyte.com/reference/errors#try-again-later", + "try-again-later", + ), + (500, "https://reference.airbyte.com/reference/errors", "unexpected-problem"), + (400, "https://reference.airbyte.com/reference/errors#bad-request", "bad-request"), + ( + 501, + "error:implementation/not-implemented-in-oss", + "API not implemented in Airbyte OSS", + ), + (400, "error:dbtcloud/access-denied", "Incorrect integration credentials"), + ( + 401, + "error:dbtcloud/paid-plan-required", + "Unable to access dbt Cloud integration", + ), + (401, "error:dbtcloud/generic", "Unable to access dbt Cloud integration"), + (400, "error:cron-validation/missing-cron-data", "Cron data is missing"), + (400, "error:cron-validation/missing-component", "Cron is missing a component"), + (400, "error:cron-validation/unsupported-timezone", "Unsupported cron timezone"), + (400, "error:cron-validation/invalid-expression", "Invalid cron expression"), + (400, "error:cron-validation/invalid-timezone", "Invalid cron timezone"), + ( + 400, + "error:cron-validation/under-one-hour-not-allowed", + "Cron sync schedules more frequent than once per hour are not allowed", + ), + ( + 400, + "error:basic-schedule-validation/under-one-hour-not-allowed", + "Basic sync schedules more frequent than once per hour are not allowed", + ), + (400, "error:mapper-validation", "Mapper validation failed"), + ( + 400, + "error:mapper-validation/missing-required-param", + "Mapper configuration missing required parameter", + ), + (400, "error:mapper-validation/invalid-config", "Invalid Mapper Configuration"), + (400, "error:mapper-validation/secret-not-found", "Mapper secret not found"), + ( + 400, + "error:connection-validation/file-transfer/connection-unsupported", + "Connection does not support file transfers", + ), + ( + 400, + "error:connection-validation/file-transfer/stream-unsupported", + "Stream does not support file transfers", + ), + ( + 400, + "error:connection-conflicting-destination-stream", + "Connection contains conflicting stream(s).", + ), + ( + 409, + "error:mapper-validation/runtime-secrets-manager-required", + "Runtime Secrets Manager Required", + ), + (503, "https://reference.airbyte.com/reference/errors", "service-unavailable"), + ( + 404, + "https://reference.airbyte.com/reference/errors#resource-not-found", + "resource-not-found", + ), + ( + 409, + "error:generate-contribution/connector-image-name-in-use", + "The image name provided is already in use.", + ), + ( + 401, + "error:generate-contribution/invalid-github-token", + "Invalid GitHub token provided.", + ), + ( + 403, + "error:generate-contribution/insufficient-github-token-permissions", + "Failed to create fork of Airbyte repository.", + ), + ( + 500, + "error:generate-contribution", + "An unexpected error occurred when creating your GitHub contribution.", + ), + ( + 404, + "error:billing/subscription/subscription-required", + "A subscription is required for this operation to succeed.", + ), + ( + 422, + "error:billing/insufficient-payment-status", + "The payment status of the associated Organization is insufficient.", + ), + ( + 422, + "error:billing/insufficient-credit-balance", + "The credit balance of the associated Workspace or Organization is insufficient.", + ), + ( + 404, + "error:billing/no-active-subscription", + "The organization doesn't have an active subscription.", + ), + ( + 400, + "error:billing/no-cancelable-subscription", + "The organization doesn't have any active cancelable subscription.", + ), + ( + 400, + "error:billing/no-scheduled-cancellation-subscription", + "The organization doesn't have any cancelable subscription with a scheduled cancellation.", + ), + ( + 400, + "error:billing/no-scheduled-plan-change", + "The organization doesn't have any self-serve subscription with a scheduled plan change.", + ), + ( + 400, + "error:connector-rollout/invalid-request", + "Invalid Connector Rollout request", + ), + ( + 400, + "error:connector-rollout/rollout-percentage-reached", + "Max rollout percentage already reached", + ), + ( + 400, + "error:connector-rollout/not-enough-actors", + "Not Enough Actors for Connector Rollout", + ), + ( + 409, + "error:dataplane-group-name-already-exists", + "Data plane group name already exists", + ), + (409, "error:dataplane-name-already-exists", "Data plane name already exists"), + (409, "error:tag-already-exists", "Tag already exists"), + (409, "error:group-already-exists", "Group already exists"), + (409, "error:group-permission-already-exists", "Group permission already exists"), + (409, "error:group-managed-by-scim", "Group managed by SCIM"), + (409, "error:group-member-already-exists", "Group member already exists"), + (400, "error:tag-invalid-hex-color", "Invalid hex color"), + (400, "error:tag-name-too-long", "Tag name too long"), + (400, "error:tag-limit-for-workspace-reached", "Tag limit for workspace reached"), + ( + 403, + "error:workspace-limit-for-organization-reached", + "Workspace limit for organization reached", + ), + (408, "error:request-timeout-exceeded", "Request timeout exceeded"), + (400, "error:notification/config/required", "Notification required"), + (400, "error:notification/config/missing-url", "Notification missing URL"), + ( + 400, + "error:destination/discover-not-supported", + "Destination does not support discover", + ), + (404, "error:destination/catalog-not-found", "Destination catalog not found"), + ( + 400, + "error:connection/destination-catalog/missing-object-name", + "Configured stream missing destination object name", + ), + ( + 400, + "error:connection/destination-catalog/invalid-operation", + "Invalid destination operation configuration", + ), + ( + 400, + "error:connection/destination-catalog/missing-required-field", + "Configured stream missing required field", + ), + ( + 400, + "error:connection/destination-catalog/invalid-additional-field", + "Invalid additional field in configured stream", + ), + ( + 400, + "error:connection/destination-catalog/required", + "Destination catalog is required", + ), + ( + 400, + "error:connection/destination-catalog/missing-primary-key", + "Primary key required when matching keys are defined", + ), + ( + 400, + "error:connection/destination-catalog/invalid-primary-key", + "Primary key must match one of the matching keys", + ), + (500, "error:sso-config-retrieval", "SSO config retrieval error"), + (500, "error:sso-setup", "SSO setup configuration error"), + (500, "error:sso-deletion", "SSO deletion failed"), + (500, "error:sso-credential-update", "SSO credential update failed"), + (500, "error:sso-activation", "SSO Activation Error"), + (401, "error:sso-token-validation", "SSO Token Validation Failed"), + ( + 503, + "error:entitlement-service/error-adding-organization", + "entitlement-service-error-adding-organization", + ), + ( + 500, + "error:entitlement-service/invalid-organization-state", + "entitlement-service-invalid-organization-state", + ), +] + + +@pytest.mark.parametrize( + ("status_code", "problem_type", "title"), + [pytest.param(*row, id=row[1]) for row in _PROBLEM_TYPE_DEFAULTS], +) +def test_every_published_problem_type_matches_a_fixed_message( + status_code: int, + problem_type: str, + title: str, +) -> None: + """Each published `type`/`title` pair hits a `_PROBLEM_MESSAGES` row.""" + body = _problem_body(problem_type, title) + wrapped = api_util._wrap_sdk_error(_sdk_error(status_code, body)) + + if problem_type.endswith("409-actor-not-ready"): + assert type(wrapped) is AirbyteConnectorNotReadyError + return + + if status_code == 403: + assert type(wrapped) is AirbyteMissingResourceError + assert wrapped.get_message() == ( + "The requested resource was not found, or these credentials can't " + "access it (HTTP 403)." + ) + assert wrapped.guidance == api_util.FORBIDDEN_RESOURCE_GUIDANCE + return + + problem = api_util.parse_cloud_problem(status_code, body) + assert problem.key in api_util._PROBLEM_MESSAGES + expected_message, expected_guidance = api_util._PROBLEM_MESSAGES[problem.key] + # A table hit can never be the status fallback; a few rows (bad-request, + # state-conflict, ...) intentionally share the fallback wording anyway. + assert wrapped.get_message() == ( + f"{expected_message} (Cloud error: {problem.key}, HTTP {status_code})" + ) + assert wrapped.guidance == expected_guidance + assert _INTERNAL_MARKER not in wrapped.get_message() From c6ae9d2771c6a8c350369f7602c62b24a21357d2 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 04:31:33 +0000 Subject: [PATCH 12/22] fix(mcp): mask echoed values as whole tokens and read problem titles only for generic types Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_util/api_util.py | 33 ++++++++----- airbyte/mcp/_error_handling.py | 33 ++++++++----- tests/unit_tests/test_cloud_api_util.py | 44 ++++++++++++++++++ tests/unit_tests/test_mcp_tool_errors.py | 59 ++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 23 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index c0b9bb683..c62748013 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -199,19 +199,28 @@ class CloudProblem: def _problem_slug(problem: dict[str, Any]) -> str | None: - """Return the problem slug: the last `type` segment, or `title` for the generic type.""" - for field in ("type", "title"): - value = problem.get(field) + """Return the problem slug: the last `type` segment; `title` only for the generic type. + + A `type` whose slug is rejected (ID-like, or failing the slug charset) gives + `None` rather than a `title` read. + """ + + def slug_of(value: object) -> str | None: if not isinstance(value, str): - continue + return None slug = re.split(r"[/#]", value)[-1] - if ( - slug != _GENERIC_PROBLEM_SLUG - and _PROBLEM_SLUG.fullmatch(slug) - and not _ID_LIKE.fullmatch(slug) - ): + if _PROBLEM_SLUG.fullmatch(slug) and not _ID_LIKE.fullmatch(slug): return slug - return None + return None + + slug = slug_of(problem.get("type")) + if slug is None: + if isinstance(problem.get("type"), str): + return None + return slug_of(problem.get("title")) + if slug == _GENERIC_PROBLEM_SLUG: + return slug_of(problem.get("title")) + return slug _PROBLEM_TYPE_PREFIX = "https://reference.airbyte.com/reference/errors" @@ -233,9 +242,11 @@ def _problem_key(problem: dict[str, Any]) -> str | None: key = key.removeprefix("error:") if key.startswith("409-"): key = key.removeprefix("409-") - if not key: + if key in {"", _GENERIC_PROBLEM_SLUG}: title = problem.get("title") key = title if isinstance(title, str) else "" + if _ID_LIKE.fullmatch(key): + return None return key or None diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index eb223d7b7..4d7b40e02 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -149,19 +149,19 @@ def _collect_leaves( path: str, *, secret: bool, - leaves: list[tuple[str, str]], + leaves: list[tuple[str, str, bool]], ) -> None: - """Collect (placeholder path, leaf text) pairs for every value to hide.""" + """Collect (placeholder path, leaf text, secret) tuples for values to hide.""" if isinstance(value, bool): return # `true`/`false` are JSON literals, never masked. if isinstance(value, (int, float)): leaf = str(value) - if (secret and _secret_leaf(leaf)) or (not secret and len(leaf) >= 4): # noqa: PLR2004 - leaves.append((path, leaf)) + if (secret and _secret_leaf(leaf)) or (not secret and len(leaf) >= 8): # noqa: PLR2004 + leaves.append((path, leaf, secret)) return if isinstance(value, str): - if (secret and _secret_leaf(value)) or (not secret and len(value) >= 4): # noqa: PLR2004 - leaves.append((path, value)) + if (secret and _secret_leaf(value)) or (not secret and len(value) >= 8): # noqa: PLR2004 + leaves.append((path, value, secret)) # A JSON-string config may hold nested secrets; index its leaves too. if value[:1] in {"{", "["}: try: @@ -207,9 +207,10 @@ def redact_agent_text( Each parameter is classified by the same rules arg tracing uses, so error text never shows a value tracing would not record in clear: secret-class - parameters lose every leaf, other parameters lose leaves of four or more - characters, and `VALUE` (enum-like) parameters stay clear only when they - hold one of the declared choices. + parameters lose every leaf, other parameters lose leaves of eight or + more characters, and `VALUE` (enum-like) parameters stay clear only when + they hold one of the declared choices. Non-secret leaves are masked as + whole tokens only, so common words in guidance text stay untouched. """ if arguments: func = getattr(tool, "fn", None) if tool is not None else None @@ -217,7 +218,7 @@ def redact_agent_text( classes = classify_tool(func, sorted(arguments), tool=getattr(tool, "name", "")) except Exception: classes = {} - leaves: list[tuple[str, str]] = [] + leaves: list[tuple[str, str, bool]] = [] for name, value in arguments.items(): arg_class = classes.get(name) mode = arg_class.mode if arg_class is not None else TraceArg.PRESENCE @@ -229,11 +230,19 @@ def redact_agent_text( secret = mode in {TraceArg.OMIT, TraceArg.PRESENCE} _collect_leaves(value, name, secret=secret, leaves=leaves) seen: set[str] = set() - for path, leaf in sorted(leaves, key=lambda item: -len(item[1])): + for path, leaf, secret in sorted(leaves, key=lambda item: -len(item[1])): if leaf in seen: continue seen.add(leaf) - text = text.replace(leaf, f"") + placeholder = f"" + if secret: + text = text.replace(leaf, placeholder) + else: + text = re.sub( + rf"(?", text) text = _BEARER_TOKEN.sub("Bearer ", text) diff --git a/tests/unit_tests/test_cloud_api_util.py b/tests/unit_tests/test_cloud_api_util.py index 26a6134da..051ecc345 100644 --- a/tests/unit_tests/test_cloud_api_util.py +++ b/tests/unit_tests/test_cloud_api_util.py @@ -3277,3 +3277,47 @@ def test_every_published_problem_type_matches_a_fixed_message( ) assert wrapped.guidance == expected_guidance assert _INTERNAL_MARKER not in wrapped.get_message() + + +@pytest.mark.parametrize( + ("problem", "expected_slug", "expected_key"), + [ + pytest.param( + { + "type": ( + "https://reference.airbyte.com/reference/errors" + "#f47ac10b-58cc-4372-a567-0e02b2c3d479" + ), + "title": "resource-not-found", + }, + None, + None, + id="id-like-type-never-reads-title", + ), + pytest.param( + { + "type": "https://reference.airbyte.com/reference/errors", + "title": "unexpected-problem", + }, + "unexpected-problem", + "unexpected-problem", + id="generic-type-reads-title", + ), + pytest.param( + {"title": "unexpected-problem"}, + "unexpected-problem", + "unexpected-problem", + id="missing-type-reads-title", + ), + ], +) +def test_parse_cloud_problem_title_fallback_only_for_generic_type( + problem: dict[str, str], + expected_slug: str | None, + expected_key: str | None, +) -> None: + """`title` is read only when `type` is absent or the generic errors URL.""" + parsed = api_util.parse_cloud_problem(None, json.dumps(problem)) + + assert parsed.slug == expected_slug + assert parsed.key == expected_key diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index 6cb9a82e5..7865367b9 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -5,6 +5,7 @@ import asyncio import json +from types import SimpleNamespace from typing import Literal import pytest @@ -26,6 +27,7 @@ from airbyte.mcp._error_handling import ( MCP_TOOL_USER_FACING_ERRORS, AgentErrorTextMiddleware, + redact_agent_text, classify_mcp_tool_error, format_user_facing_error, mcp_tool_error_reason, @@ -317,3 +319,60 @@ def read_records(config: dict | str | None, stream_name: str) -> str: assert "sk_live" not in text assert "" in text assert "stream_with_secret_value" not in text + + +def _tool_stub(fn): + return SimpleNamespace(fn=fn, name="test-tool") + + +def _classify_args(stream_name: str, connection_id: str, password: str) -> None: + """Hints so `classify_tool` gives HASH/PRESENCE modes like real tools.""" + + +@pytest.mark.parametrize( + ("arguments", "text", "expected"), + [ + pytest.param( + {"stream_name": "user"}, + "Ask the user; don't retry.", + "Ask the user; don't retry.", + id="non-secret-word-user", + ), + pytest.param( + {"stream_name": "retry"}, + "Ask the user; don't retry.", + "Ask the user; don't retry.", + id="non-secret-word-retry", + ), + pytest.param( + {"stream_name": "users"}, + "Ask the user; don't retry.", + "Ask the user; don't retry.", + id="non-secret-word-users", + ), + pytest.param( + {"stream_name": "customer"}, + "Check the customers table first.", + "Check the customers table first.", + id="non-secret-token-inside-longer-token", + ), + pytest.param( + {"connection_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + "Cannot perform destructive operation on " + "'f47ac10b-58cc-4372-a567-0e02b2c3d479': Object was not created.", + "Cannot perform destructive operation on " + "'': Object was not created.", + id="non-secret-uuid-echo-masked", + ), + pytest.param( + {"password": "abc123secret"}, + "boom in xxabc123secretyy field", + "boom in xxyy field", + id="secret-substring-masked", + ), + ], +) +def test_redact_agent_text_masks_whole_tokens_only( + arguments: dict, text: str, expected: str +) -> None: + assert redact_agent_text(text, arguments, _tool_stub(_classify_args)) == expected From bf6340b07a07ef5c7d94f98c35a7e583488c9dcd Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 04:36:59 +0000 Subject: [PATCH 13/22] refactor(mcp): move Cloud error messages into a YAML data file Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_util/api_util.py | 481 +----------------------- airbyte/_util/cloud_errors.py | 180 +++++++++ airbyte/_util/cloud_errors.yaml | 273 ++++++++++++++ airbyte/mcp/_error_handling.py | 15 +- tests/unit_tests/test_cloud_api_util.py | 32 +- 5 files changed, 492 insertions(+), 489 deletions(-) create mode 100644 airbyte/_util/cloud_errors.py create mode 100644 airbyte/_util/cloud_errors.yaml diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index c62748013..16d5d4d04 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -17,8 +17,7 @@ import copy import json import logging -import re -from dataclasses import dataclass, replace +from dataclasses import replace from http import HTTPStatus from typing import TYPE_CHECKING, Any, Literal, TypeVar @@ -27,6 +26,7 @@ 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, @@ -172,469 +172,6 @@ def error_response_body(error: BaseException) -> str | None: return None -_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") -# The last path segment of the generic problem `type` URL; `title` is the slug then. -_GENERIC_PROBLEM_SLUG = "errors" -# A slug names a kind of problem; one that is or holds an ID names a resource. -_ID_LIKE = re.compile(r"\d+|.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*") -_MAX_PROBLEM_BODY = 65536 -_RESOURCE_TYPE = re.compile(r"[a-z_-]{1,40}") -_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") - - -@dataclass(frozen=True) -class CloudProblem: - """The allow-listed fields of an Airbyte Cloud error body. - - `detail`, `data.message` and every other field can quote caller values or - server internals, so they are never read for message text. - """ - - status_code: int | None - slug: str | None # Last `type`/`title` segment, like `mcp_tool_error_reason`. - key: str | None # Normalized `type` path used to look up the fixed message. - resource_type: str | None # `data.resourceType`, a fixed word. - limit: int | None # `data.limit`. - error_id: str | None # Top-level `errorId` of a non-problem body. - - -def _problem_slug(problem: dict[str, Any]) -> str | None: - """Return the problem slug: the last `type` segment; `title` only for the generic type. - - A `type` whose slug is rejected (ID-like, or failing the slug charset) gives - `None` rather than a `title` read. - """ - - def slug_of(value: object) -> str | None: - if not isinstance(value, str): - return None - slug = re.split(r"[/#]", value)[-1] - if _PROBLEM_SLUG.fullmatch(slug) and not _ID_LIKE.fullmatch(slug): - return slug - return None - - slug = slug_of(problem.get("type")) - if slug is None: - if isinstance(problem.get("type"), str): - return None - return slug_of(problem.get("title")) - if slug == _GENERIC_PROBLEM_SLUG: - return slug_of(problem.get("title")) - return slug - - -_PROBLEM_TYPE_PREFIX = "https://reference.airbyte.com/reference/errors" - - -def _problem_key(problem: dict[str, Any]) -> str | None: - """Return the normalized `type` path used to look up the fixed message. - - Cloud emits `type` as the bare errors URL (slug in `title`), as the URL - plus `#`, or as `error:`; all three normalize to the same key. - """ - value = problem.get("type") - key = value if isinstance(value, str) else "" - if key.startswith(_PROBLEM_TYPE_PREFIX): - key = key.removeprefix(_PROBLEM_TYPE_PREFIX) - if key[:1] in {"#", "/"}: - key = key[1:] - if key.startswith("error:"): - key = key.removeprefix("error:") - if key.startswith("409-"): - key = key.removeprefix("409-") - if key in {"", _GENERIC_PROBLEM_SLUG}: - title = problem.get("title") - key = title if isinstance(title, str) else "" - if _ID_LIKE.fullmatch(key): - return None - return key or None - - -def parse_cloud_problem(status_code: int | None, body: str | None) -> CloudProblem: - """Read only the allow-listed fields of a Cloud API error body. - - Bodies over 64 KiB or non-JSON give `slug=None`. - """ - slug = key = resource_type = error_id = None - limit = None - if body is not None and len(body) <= _MAX_PROBLEM_BODY: - try: - problem = json.loads(body) - except (ValueError, RecursionError): - problem = None - if isinstance(problem, dict): - slug = _problem_slug(problem) - key = _problem_key(problem) - data = problem.get("data") - if isinstance(data, dict): - candidate = data.get("resourceType") - if isinstance(candidate, str) and _RESOURCE_TYPE.fullmatch(candidate): - resource_type = candidate - limit_value = data.get("limit") - if isinstance(limit_value, int) and not isinstance(limit_value, bool): - limit = limit_value - candidate = problem.get("errorId") - if isinstance(candidate, str) and _UUID.fullmatch(candidate): - error_id = candidate - return CloudProblem( - status_code=status_code, - slug=slug, - key=key, - resource_type=resource_type, - limit=limit, - error_id=error_id, - ) - - -_SCHEDULE_INVALID_PROBLEM = ( - "The connection schedule is invalid.", - "Fix the schedule (cron expression, timezone, or frequency) and retry.", -) -_MAPPER_INVALID_PROBLEM = ( - "The mapper configuration is invalid.", - "Fix the mapper configuration and retry. " - "If a mapper secret is missing, resend the secret values.", -) -_STREAM_SELECTION_PROBLEM = ( - "The selected streams can't be used with this connection (file transfer is " - "unsupported, or a stream collides with another connection).", - "Change the selected streams or connection settings and retry.", -) -_DESTINATION_CATALOG_PROBLEM = ( - "The destination catalog settings for a stream are invalid.", - "Fix the stream's destination settings (object name, sync mode, fields, " - "primary key) and retry.", -) -_VALUE_REJECTED_PROBLEM = ( - "Airbyte Cloud rejected a value in the request.", - "Fix the input and retry.", -) -_ROLLOUT_REJECTED_PROBLEM = ( - "The connector rollout request was rejected.", - "Don't retry; tell the user.", -) -_ALREADY_EXISTS_PROBLEM = ( - "A resource with this name or membership already exists.", - "Use the existing one or pick another name; don't retry as is.", -) -_CLOUD_OPERATION_FAILED_PROBLEM = ( - "Airbyte Cloud failed to complete this operation.", - "Don't retry; tell the user.", -) -_DBTCLOUD_PROBLEM = ( - "dbt Cloud rejected the integration credentials, requires a paid plan, " "or the call failed.", - "Ask the user to check the dbt Cloud integration.", -) -_ENDPOINT_UNAVAILABLE_PROBLEM = ( - "This operation isn't available at this Airbyte endpoint.", - "Don't retry.", -) -_RESOURCE_NOT_FOUND_PROBLEM = ( - "The resource was not found.", - "Check the ID; list the resources to find the right one.", -) -_INVALID_API_KEY_PROBLEM = ( - "Airbyte Cloud rejected the credentials.", - "Ask the user to check the client ID/secret or token; don't retry.", -) -_REQUEST_TIMEOUT_PROBLEM = ( - "Airbyte Cloud timed out. The change may or may not have been applied.", - "Check the resource's current state before retrying; don't blindly retry a create.", -) -_STATE_CONFLICT_PROBLEM = ( - "The resource is in a state that doesn't allow this operation (for example, " - "a job is running or already finished).", - "Check the resource's current status before retrying.", -) -_UNEXPECTED_PROBLEM = ( - "Airbyte Cloud hit an unexpected error. This is often caused by an invalid " "argument value.", - "Check the arguments (ranges, IDs, formats). If they look right, don't retry " - "more than once; tell the user.", -) -_RATE_LIMITED_PROBLEM = ( - "Airbyte Cloud is refusing requests from these credentials (rate limited).", - "Stop calling Airbyte Cloud tools and tell the user; don't retry.", -) -_REQUEST_REJECTED_PROBLEM = ( - "Airbyte Cloud rejected the request as invalid.", - "Check the arguments against the tool description.", -) -_CLOUD_ERROR_PROBLEM = ( - "Airbyte Cloud returned an error.", - "Retry at most once; tell the user if it persists.", -) - -# Each key is the normalized `type` path (`error:` -> ``, -# `.../errors#` -> ``, bare errors URL -> `title`). -_PROBLEM_MESSAGES: dict[str, tuple[str, str]] = { - "resource-not-found": _RESOURCE_NOT_FOUND_PROBLEM, - "destination/catalog-not-found": ( - "This connection has no destination catalog yet.", - "Set up the destination catalog before this operation; do not change the ID.", - ), - "billing/subscription/subscription-required": ( - "This organization needs an active Airbyte subscription for this operation.", - "Ask the user to check billing; don't retry.", - ), - "billing/no-active-subscription": ( - "This organization needs an active Airbyte subscription for this operation.", - "Ask the user to check billing; don't retry.", - ), - "billing/insufficient-payment-status": ( - "The organization's payment status or credit balance doesn't allow this.", - "Ask the user to resolve billing; don't retry.", - ), - "billing/insufficient-credit-balance": ( - "The organization's payment status or credit balance doesn't allow this.", - "Ask the user to resolve billing; don't retry.", - ), - "invalid-api-key": _INVALID_API_KEY_PROBLEM, - "sso-token-validation": ( - "The SSO access token is invalid or expired.", - "Ask the user to sign in again.", - ), - "auth/sso-required": ( - "This email domain must sign in with SSO.", - "Ask the user to sign in through SSO.", - ), - "generate-contribution/invalid-github-token": ( - "The GitHub token is invalid or lacks repo write permission.", - "Ask the user for a valid token.", - ), - "generate-contribution/insufficient-github-token-permissions": ( - "The GitHub token is invalid or lacks repo write permission.", - "Ask the user for a valid token.", - ), - "license/entitlement": ( - "The organization's plan doesn't allow this.", - "Ask the user; don't retry.", - ), - "workspace-limit-for-organization-reached": ( - "The organization's plan doesn't allow this.", - "Ask the user; don't retry.", - ), - "value-not-found": ( - "Airbyte doesn't recognize the connector type name.", - "Check the name against the connector list (e.g. `source-postgres`).", - ), - "bad-request": _REQUEST_REJECTED_PROBLEM, - "unprocessable-entity": ( - "The request body or connector configuration failed validation.", - "Fix the configuration and retry.", - ), - "invalid-redirect-url": ( - "The redirect URL is not a valid HTTPS URL.", - "Fix the configuration and retry.", - ), - "state-conflict": _STATE_CONFLICT_PROBLEM, - "connection/locked": ( - "The connection is locked and can't be changed right now.", - "Don't retry; ask the user.", - ), - "try-again-later": ( - "Airbyte Cloud is busy with a conflicting change.", - "Wait and retry once.", - ), - "group-managed-by-scim": ( - "This group is managed by SCIM and can't be changed here.", - "Don't retry; tell the user.", - ), - "mapper-validation/runtime-secrets-manager-required": ( - "This needs a runtime secrets manager configured.", - "Ask the user.", - ), - "failed-precondition": ( - "A required condition for this operation isn't met.", - "Check the resource's state; ask the user if unclear.", - ), - "request-timeout-exceeded": _REQUEST_TIMEOUT_PROBLEM, - "unexpected-problem": _UNEXPECTED_PROBLEM, - "service-unavailable": ( - "Airbyte Cloud is temporarily unavailable.", - "Wait and retry once.", - ), - "entitlement-service/error-adding-organization": ( - "Airbyte Cloud is temporarily unavailable.", - "Wait and retry once.", - ), - "tag-limit-for-workspace-reached": ( - "The workspace has reached its tag limit.", - "Delete a tag first or ask the user.", - ), -} -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "cron-validation/missing-cron-data", - "cron-validation/missing-component", - "cron-validation/unsupported-timezone", - "cron-validation/invalid-expression", - "cron-validation/invalid-timezone", - "cron-validation/under-one-hour-not-allowed", - "basic-schedule-validation/under-one-hour-not-allowed", - ), - _SCHEDULE_INVALID_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "mapper-validation", - "mapper-validation/missing-required-param", - "mapper-validation/invalid-config", - "mapper-validation/secret-not-found", - ), - _MAPPER_INVALID_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "connection-validation/file-transfer/connection-unsupported", - "connection-validation/file-transfer/stream-unsupported", - "connection-conflicting-destination-stream", - ), - _STREAM_SELECTION_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "connection/destination-catalog/missing-object-name", - "connection/destination-catalog/invalid-operation", - "connection/destination-catalog/missing-required-field", - "connection/destination-catalog/invalid-additional-field", - "connection/destination-catalog/required", - "connection/destination-catalog/missing-primary-key", - "connection/destination-catalog/invalid-primary-key", - "destination/discover-not-supported", - ), - _DESTINATION_CATALOG_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "tag-invalid-hex-color", - "tag-name-too-long", - "notification/config/required", - "notification/config/missing-url", - ), - _VALUE_REJECTED_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "connector-rollout/invalid-request", - "connector-rollout/rollout-percentage-reached", - "connector-rollout/not-enough-actors", - ), - _ROLLOUT_REJECTED_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "auth/user-already-exists", - "tag-already-exists", - "group-already-exists", - "group-permission-already-exists", - "group-member-already-exists", - "dataplane-group-name-already-exists", - "dataplane-name-already-exists", - "generate-contribution/connector-image-name-in-use", - ), - _ALREADY_EXISTS_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "oauth-callback-failure", - "invalid-consent-url", - "generate-contribution", - "sso-config-retrieval", - "sso-setup", - "sso-deletion", - "sso-credential-update", - "sso-activation", - "entitlement-service/invalid-organization-state", - ), - _CLOUD_OPERATION_FAILED_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ("dbtcloud/access-denied", "dbtcloud/paid-plan-required", "dbtcloud/generic"), - _DBTCLOUD_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ("embedded/endpoint-moved", "implementation/not-implemented-in-oss"), - _ENDPOINT_UNAVAILABLE_PROBLEM, - ) -) -_PROBLEM_MESSAGES.update( - dict.fromkeys( - ( - "billing/no-cancelable-subscription", - "billing/no-scheduled-cancellation-subscription", - "billing/no-scheduled-plan-change", - ), - ( - "There is no subscription change of this kind to act on.", - "Don't retry; tell the user.", - ), - ) -) - - -_STATUS_FALLBACK_PROBLEMS: dict[int, tuple[str, str]] = { - HTTPStatus.UNAUTHORIZED: _INVALID_API_KEY_PROBLEM, - HTTPStatus.NOT_FOUND: _RESOURCE_NOT_FOUND_PROBLEM, - HTTPStatus.REQUEST_TIMEOUT: _REQUEST_TIMEOUT_PROBLEM, - HTTPStatus.CONFLICT: _STATE_CONFLICT_PROBLEM, - HTTPStatus.TOO_MANY_REQUESTS: _RATE_LIMITED_PROBLEM, -} - - -def _status_fallback_message(status_code: int | None) -> tuple[str, str]: - """Return (message, guidance) for a status without a known problem slug.""" - if status_code is not None and status_code in _STATUS_FALLBACK_PROBLEMS: - return _STATUS_FALLBACK_PROBLEMS[status_code] - if status_code is None: - return _CLOUD_ERROR_PROBLEM - if status_code < HTTPStatus.INTERNAL_SERVER_ERROR: - return _REQUEST_REJECTED_PROBLEM - return _UNEXPECTED_PROBLEM - - -def describe_cloud_problem(problem: CloudProblem) -> tuple[str, str]: - """Return (message, guidance) for a parsed Cloud problem.""" - message, guidance = _PROBLEM_MESSAGES.get( - problem.key or "", _status_fallback_message(problem.status_code) - ) - if problem.key == "resource-not-found" and problem.resource_type: - message = f"The {problem.resource_type} was not found." - elif problem.key == "workspace-limit-for-organization-reached" and problem.limit is not None: - message += f" (limit: {problem.limit} workspaces)" - if problem.status_code is not None: - message += ( - f" (Cloud error: {problem.key}, HTTP {problem.status_code})" - if problem.key - else f" (HTTP {problem.status_code})" - ) - if ( - problem.status_code is not None - and problem.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR - and problem.error_id - ): - guidance += f" Error ID: {problem.error_id}." - return message, guidance - - 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: @@ -662,7 +199,7 @@ 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) - problem = parse_cloud_problem(error.status_code, error.body) + problem = parse_cloud_error(error.status_code, error.body) merged_context = { **(base_context or {}), **sdk_context, @@ -679,7 +216,7 @@ def _wrap_sdk_error( status_code = sdk_context.get("status_code") is_forbidden = status_code == HTTPStatus.FORBIDDEN - message, guidance = describe_cloud_problem(problem) + message, guidance = describe_cloud_error(problem) error_type = ( AirbyteMissingResourceError if is_forbidden or status_code == HTTPStatus.NOT_FOUND @@ -2465,7 +2002,7 @@ 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_problem(parse_cloud_problem(e.status_code, e.body)) + wrapped_message, _ = describe_cloud_error(parse_cloud_error(e.status_code, e.body)) raise AirbyteLibInputError( message=wrapped_message, guidance=( @@ -2814,8 +2351,8 @@ def _make_config_api_request( try: response.raise_for_status() except requests.HTTPError as ex: - problem = parse_cloud_problem(response.status_code, response.text) - message, guidance = describe_cloud_problem(problem) + 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, @@ -2901,8 +2438,8 @@ def _get_public_api_json( try: response.raise_for_status() except requests.HTTPError as ex: - problem = parse_cloud_problem(response.status_code, response.text) - message, guidance = describe_cloud_problem(problem) + problem = parse_cloud_error(response.status_code, response.text) + message, guidance = describe_cloud_error(problem) error_context = { "full_url": full_url, "path": path, diff --git a/airbyte/_util/cloud_errors.py b/airbyte/_util/cloud_errors.py new file mode 100644 index 000000000..041cf0832 --- /dev/null +++ b/airbyte/_util/cloud_errors.py @@ -0,0 +1,180 @@ +# Copyright (c) 2026 Airbyte, Inc., all rights reserved. +"""Fixed agent-facing messages for Airbyte Cloud problem (RFC 9457) error bodies. + +`parse_cloud_error` reads only the allow-listed fields of a Cloud error body, +`describe_cloud_error` maps it to the fixed `(message, guidance)` text stored in +`cloud_errors.yaml`, and `cloud_errors.yaml` is the single source of those +strings. `detail`, `data.message` and every other body field can quote caller +values or server internals, so they are never read for message text. +""" + +from __future__ import annotations + +import functools +import importlib.resources +import json +import re +from dataclasses import dataclass +from http import HTTPStatus +from typing import Any + +import yaml + + +_PROBLEM_SLUG = re.compile(r"[a-z0-9][a-z0-9:._-]{0,99}") +# The last path segment of the generic problem `type` URL; `title` is the slug then. +_GENERIC_PROBLEM_SLUG = "errors" +# A slug names a kind of problem; one that is or holds an ID names a resource. +_ID_LIKE = re.compile(r"\d+|.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*") +_MAX_PROBLEM_BODY = 65536 +_RESOURCE_TYPE = re.compile(r"[a-z_-]{1,40}") +_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") +_PROBLEM_TYPE_PREFIX = "https://reference.airbyte.com/reference/errors" + + +@dataclass(frozen=True) +class CloudErrorInfo: + """The allow-listed fields of an Airbyte Cloud error body. + + `detail`, `data.message` and every other field can quote caller values or + server internals, so they are never read for message text. + """ + + status_code: int | None + slug: str | None # Last `type`/`title` segment, like `mcp_tool_error_reason`. + key: str | None # Normalized `type` path used to look up the fixed message. + resource_type: str | None # `data.resourceType`, a fixed word. + limit: int | None # `data.limit`. + error_id: str | None # Top-level `errorId` of a non-problem body. + + +def _problem_slug(problem: dict[str, Any]) -> str | None: + """Return the problem slug: the last `type` segment; `title` only for the generic type. + + A `type` whose slug is rejected (ID-like, or failing the slug charset) gives + `None` rather than a `title` read. + """ + + def slug_of(value: object) -> str | None: + if not isinstance(value, str): + return None + slug = re.split(r"[/#]", value)[-1] + if _PROBLEM_SLUG.fullmatch(slug) and not _ID_LIKE.fullmatch(slug): + return slug + return None + + slug = slug_of(problem.get("type")) + if slug is None: + if isinstance(problem.get("type"), str): + return None + return slug_of(problem.get("title")) + if slug == _GENERIC_PROBLEM_SLUG: + return slug_of(problem.get("title")) + return slug + + +def _problem_key(problem: dict[str, Any]) -> str | None: + """Return the normalized `type` path used to look up the fixed message. + + Cloud emits `type` as the bare errors URL (slug in `title`), as the URL + plus `#`, or as `error:`; all three normalize to the same key. + """ + value = problem.get("type") + key = value if isinstance(value, str) else "" + if key.startswith(_PROBLEM_TYPE_PREFIX): + key = key.removeprefix(_PROBLEM_TYPE_PREFIX) + if key[:1] in {"#", "/"}: + key = key[1:] + if key.startswith("error:"): + key = key.removeprefix("error:") + if key.startswith("409-"): + key = key.removeprefix("409-") + if key in {"", _GENERIC_PROBLEM_SLUG}: + title = problem.get("title") + key = title if isinstance(title, str) else "" + if _ID_LIKE.fullmatch(key): + return None + return key or None + + +def parse_cloud_error(status_code: int | None, body: str | None) -> CloudErrorInfo: + """Read only the allow-listed fields of a Cloud API error body. + + Bodies over 64 KiB or non-JSON give `slug=None`. + """ + slug = key = resource_type = error_id = None + limit = None + if body is not None and len(body) <= _MAX_PROBLEM_BODY: + try: + problem = json.loads(body) + except (ValueError, RecursionError): + problem = None + if isinstance(problem, dict): + slug = _problem_slug(problem) + key = _problem_key(problem) + data = problem.get("data") + if isinstance(data, dict): + candidate = data.get("resourceType") + if isinstance(candidate, str) and _RESOURCE_TYPE.fullmatch(candidate): + resource_type = candidate + limit_value = data.get("limit") + if isinstance(limit_value, int) and not isinstance(limit_value, bool): + limit = limit_value + candidate = problem.get("errorId") + if isinstance(candidate, str) and _UUID.fullmatch(candidate): + error_id = candidate + return CloudErrorInfo( + status_code=status_code, + slug=slug, + key=key, + resource_type=resource_type, + limit=limit, + error_id=error_id, + ) + + +@functools.cache +def _messages() -> dict[str, Any]: + """Return the fixed error text table from `cloud_errors.yaml`.""" + path = importlib.resources.files("airbyte._util").joinpath("cloud_errors.yaml") + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _status_fallback_message(status_code: int | None) -> tuple[str, str]: + """Return (message, guidance) for a status without a known problem key.""" + fallbacks = _messages()["status_fallbacks"] + if status_code in fallbacks: + entry = fallbacks[status_code] + elif status_code is None: + entry = fallbacks["default"] + elif status_code < HTTPStatus.INTERNAL_SERVER_ERROR: + entry = fallbacks["4xx"] + else: + entry = fallbacks["5xx"] + return entry["message"], entry["guidance"] + + +def describe_cloud_error(problem: CloudErrorInfo) -> tuple[str, str]: + """Return (message, guidance) for a parsed Cloud problem.""" + entry = _messages().get(problem.key or "") + if entry is None: + message, guidance = _status_fallback_message(problem.status_code) + else: + message, guidance = entry["message"], entry["guidance"] + if problem.key == "resource-not-found" and problem.resource_type: + message = f"The {problem.resource_type} was not found." + elif problem.key == "workspace-limit-for-organization-reached" and problem.limit is not None: + message += f" (limit: {problem.limit} workspaces)" + if problem.status_code is not None: + message += ( + f" (Cloud error: {problem.key}, HTTP {problem.status_code})" + if problem.key + else f" (HTTP {problem.status_code})" + ) + if ( + problem.status_code is not None + and problem.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR + and problem.error_id + ): + guidance += f" Error ID: {problem.error_id}." + return message, guidance diff --git a/airbyte/_util/cloud_errors.yaml b/airbyte/_util/cloud_errors.yaml new file mode 100644 index 000000000..6ba5d7d48 --- /dev/null +++ b/airbyte/_util/cloud_errors.yaml @@ -0,0 +1,273 @@ +# Agent-facing messages for Airbyte Cloud problem types. +# Keys are normalized problem types (see `_problem_key` in cloud_errors.py). +auth/sso-required: + guidance: Ask the user to sign in through SSO. + message: This email domain must sign in with SSO. +auth/user-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +bad-request: + guidance: Check the arguments against the tool description. + message: Airbyte Cloud rejected the request as invalid. +basic-schedule-validation/under-one-hour-not-allowed: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +billing/insufficient-credit-balance: + guidance: Ask the user to resolve billing; don't retry. + message: The organization's payment status or credit balance doesn't allow this. +billing/insufficient-payment-status: + guidance: Ask the user to resolve billing; don't retry. + message: The organization's payment status or credit balance doesn't allow this. +billing/no-active-subscription: + guidance: Ask the user to check billing; don't retry. + message: This organization needs an active Airbyte subscription for this operation. +billing/no-cancelable-subscription: + guidance: Don't retry; tell the user. + message: There is no subscription change of this kind to act on. +billing/no-scheduled-cancellation-subscription: + guidance: Don't retry; tell the user. + message: There is no subscription change of this kind to act on. +billing/no-scheduled-plan-change: + guidance: Don't retry; tell the user. + message: There is no subscription change of this kind to act on. +billing/subscription/subscription-required: + guidance: Ask the user to check billing; don't retry. + message: This organization needs an active Airbyte subscription for this operation. +connection-conflicting-destination-stream: + guidance: Change the selected streams or connection settings and retry. + message: The selected streams can't be used with this connection (file transfer is unsupported, or a stream collides with another connection). +connection-validation/file-transfer/connection-unsupported: + guidance: Change the selected streams or connection settings and retry. + message: The selected streams can't be used with this connection (file transfer is unsupported, or a stream collides with another connection). +connection-validation/file-transfer/stream-unsupported: + guidance: Change the selected streams or connection settings and retry. + message: The selected streams can't be used with this connection (file transfer is unsupported, or a stream collides with another connection). +connection/destination-catalog/invalid-additional-field: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/destination-catalog/invalid-operation: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/destination-catalog/invalid-primary-key: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/destination-catalog/missing-object-name: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/destination-catalog/missing-primary-key: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/destination-catalog/missing-required-field: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/destination-catalog/required: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +connection/locked: + guidance: Don't retry; ask the user. + message: The connection is locked and can't be changed right now. +connector-rollout/invalid-request: + guidance: Don't retry; tell the user. + message: The connector rollout request was rejected. +connector-rollout/not-enough-actors: + guidance: Don't retry; tell the user. + message: The connector rollout request was rejected. +connector-rollout/rollout-percentage-reached: + guidance: Don't retry; tell the user. + message: The connector rollout request was rejected. +cron-validation/invalid-expression: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +cron-validation/invalid-timezone: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +cron-validation/missing-component: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +cron-validation/missing-cron-data: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +cron-validation/under-one-hour-not-allowed: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +cron-validation/unsupported-timezone: + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. + message: The connection schedule is invalid. +dataplane-group-name-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +dataplane-name-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +dbtcloud/access-denied: + guidance: Ask the user to check the dbt Cloud integration. + message: dbt Cloud rejected the integration credentials, requires a paid plan, or the call failed. +dbtcloud/generic: + guidance: Ask the user to check the dbt Cloud integration. + message: dbt Cloud rejected the integration credentials, requires a paid plan, or the call failed. +dbtcloud/paid-plan-required: + guidance: Ask the user to check the dbt Cloud integration. + message: dbt Cloud rejected the integration credentials, requires a paid plan, or the call failed. +destination/catalog-not-found: + guidance: Set up the destination catalog before this operation; do not change the ID. + message: This connection has no destination catalog yet. +destination/discover-not-supported: + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. + message: The destination catalog settings for a stream are invalid. +embedded/endpoint-moved: + guidance: Don't retry. + message: This operation isn't available at this Airbyte endpoint. +entitlement-service/error-adding-organization: + guidance: Wait and retry once. + message: Airbyte Cloud is temporarily unavailable. +entitlement-service/invalid-organization-state: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +failed-precondition: + guidance: Check the resource's state; ask the user if unclear. + message: A required condition for this operation isn't met. +generate-contribution: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +generate-contribution/connector-image-name-in-use: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +generate-contribution/insufficient-github-token-permissions: + guidance: Ask the user for a valid token. + message: The GitHub token is invalid or lacks repo write permission. +generate-contribution/invalid-github-token: + guidance: Ask the user for a valid token. + message: The GitHub token is invalid or lacks repo write permission. +group-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +group-managed-by-scim: + guidance: Don't retry; tell the user. + message: This group is managed by SCIM and can't be changed here. +group-member-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +group-permission-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +implementation/not-implemented-in-oss: + guidance: Don't retry. + message: This operation isn't available at this Airbyte endpoint. +invalid-api-key: + guidance: Ask the user to check the client ID/secret or token; don't retry. + message: Airbyte Cloud rejected the credentials. +invalid-consent-url: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +invalid-redirect-url: + guidance: Fix the configuration and retry. + message: The redirect URL is not a valid HTTPS URL. +license/entitlement: + guidance: Ask the user; don't retry. + message: The organization's plan doesn't allow this. +mapper-validation: + guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. + message: The mapper configuration is invalid. +mapper-validation/invalid-config: + guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. + message: The mapper configuration is invalid. +mapper-validation/missing-required-param: + guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. + message: The mapper configuration is invalid. +mapper-validation/runtime-secrets-manager-required: + guidance: Ask the user. + message: This needs a runtime secrets manager configured. +mapper-validation/secret-not-found: + guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. + message: The mapper configuration is invalid. +notification/config/missing-url: + guidance: Fix the input and retry. + message: Airbyte Cloud rejected a value in the request. +notification/config/required: + guidance: Fix the input and retry. + message: Airbyte Cloud rejected a value in the request. +oauth-callback-failure: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +request-timeout-exceeded: + guidance: Check the resource's current state before retrying; don't blindly retry a create. + message: Airbyte Cloud timed out. The change may or may not have been applied. +resource-not-found: + guidance: Check the ID; list the resources to find the right one. + message: The resource was not found. +service-unavailable: + guidance: Wait and retry once. + message: Airbyte Cloud is temporarily unavailable. +sso-activation: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +sso-config-retrieval: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +sso-credential-update: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +sso-deletion: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +sso-setup: + guidance: Don't retry; tell the user. + message: Airbyte Cloud failed to complete this operation. +sso-token-validation: + guidance: Ask the user to sign in again. + message: The SSO access token is invalid or expired. +state-conflict: + guidance: Check the resource's current status before retrying. + message: The resource is in a state that doesn't allow this operation (for example, a job is running or already finished). +status_fallbacks: + 401: + guidance: Ask the user to check the client ID/secret or token; don't retry. + message: Airbyte Cloud rejected the credentials. + 404: + guidance: Check the ID; list the resources to find the right one. + message: The resource was not found. + 408: + guidance: Check the resource's current state before retrying; don't blindly retry a create. + message: Airbyte Cloud timed out. The change may or may not have been applied. + 409: + guidance: Check the resource's current status before retrying. + message: The resource is in a state that doesn't allow this operation (for example, a job is running or already finished). + 429: + guidance: Stop calling Airbyte Cloud tools and tell the user; don't retry. + message: Airbyte Cloud is refusing requests from these credentials (rate limited). + 4xx: + guidance: Check the arguments against the tool description. + message: Airbyte Cloud rejected the request as invalid. + 5xx: + guidance: Check the arguments (ranges, IDs, formats). If they look right, don't retry more than once; tell the user. + message: Airbyte Cloud hit an unexpected error. This is often caused by an invalid argument value. + default: + guidance: Retry at most once; tell the user if it persists. + message: Airbyte Cloud returned an error. +tag-already-exists: + guidance: Use the existing one or pick another name; don't retry as is. + message: A resource with this name or membership already exists. +tag-invalid-hex-color: + guidance: Fix the input and retry. + message: Airbyte Cloud rejected a value in the request. +tag-limit-for-workspace-reached: + guidance: Delete a tag first or ask the user. + message: The workspace has reached its tag limit. +tag-name-too-long: + guidance: Fix the input and retry. + message: Airbyte Cloud rejected a value in the request. +try-again-later: + guidance: Wait and retry once. + message: Airbyte Cloud is busy with a conflicting change. +unexpected-problem: + guidance: Check the arguments (ranges, IDs, formats). If they look right, don't retry more than once; tell the user. + message: Airbyte Cloud hit an unexpected error. This is often caused by an invalid argument value. +unprocessable-entity: + guidance: Fix the configuration and retry. + message: The request body or connector configuration failed validation. +value-not-found: + guidance: Check the name against the connector list (e.g. `source-postgres`). + message: Airbyte doesn't recognize the connector type name. +workspace-limit-for-organization-reached: + guidance: Ask the user; don't retry. + message: The organization's plan doesn't allow this. diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index 4d7b40e02..95530941a 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -16,11 +16,8 @@ from fastmcp_extensions.otel._extras import _chain, declared_parameters # noqa: PLC2701 from fastmcp_extensions.otel.models import TraceArg -from airbyte._util.api_util import ( - describe_cloud_problem, - error_response_body, - parse_cloud_problem, -) +from airbyte._util.api_util import error_response_body +from airbyte._util.cloud_errors import describe_cloud_error, parse_cloud_error from airbyte.exceptions import ( AirbyteAgentsUnavailableError, AirbyteConnectionSyncError, @@ -93,7 +90,7 @@ def mcp_tool_error_reason(error: BaseException) -> str | None: (body for body in map(error_response_body, _chain(error)) if body is not None), None, ) - return parse_cloud_problem(None, body).slug + return parse_cloud_error(None, body).slug def agent_error_text(error: ToolError) -> str: @@ -102,9 +99,7 @@ def agent_error_text(error: ToolError) -> str: if isinstance(cause, AirbyteLibError): return format_user_facing_error(cause) if isinstance(cause, SDKError): - message, guidance = describe_cloud_problem( - parse_cloud_problem(cause.status_code, cause.body) - ) + message, guidance = describe_cloud_error(parse_cloud_error(cause.status_code, cause.body)) return f"{message} {guidance}" return str(error) @@ -240,7 +235,7 @@ def redact_agent_text( else: text = re.sub( rf"(? None: - """Each published `type`/`title` pair hits a `_PROBLEM_MESSAGES` row.""" + """Each published `type`/`title` pair hits a `cloud_errors.yaml` row.""" body = _problem_body(problem_type, title) wrapped = api_util._wrap_sdk_error(_sdk_error(status_code, body)) @@ -3267,9 +3267,10 @@ def test_every_published_problem_type_matches_a_fixed_message( assert wrapped.guidance == api_util.FORBIDDEN_RESOURCE_GUIDANCE return - problem = api_util.parse_cloud_problem(status_code, body) - assert problem.key in api_util._PROBLEM_MESSAGES - expected_message, expected_guidance = api_util._PROBLEM_MESSAGES[problem.key] + problem = cloud_errors.parse_cloud_error(status_code, body) + assert problem.key in cloud_errors._messages() + entry = cloud_errors._messages()[problem.key] + expected_message, expected_guidance = entry["message"], entry["guidance"] # A table hit can never be the status fallback; a few rows (bad-request, # state-conflict, ...) intentionally share the fallback wording anyway. assert wrapped.get_message() == ( @@ -3311,13 +3312,30 @@ def test_every_published_problem_type_matches_a_fixed_message( ), ], ) -def test_parse_cloud_problem_title_fallback_only_for_generic_type( +def test_parse_cloud_error_title_fallback_only_for_generic_type( problem: dict[str, str], expected_slug: str | None, expected_key: str | None, ) -> None: """`title` is read only when `type` is absent or the generic errors URL.""" - parsed = api_util.parse_cloud_problem(None, json.dumps(problem)) + parsed = cloud_errors.parse_cloud_error(None, json.dumps(problem)) assert parsed.slug == expected_slug assert parsed.key == expected_key + + +def test_cloud_errors_yaml_entries_have_message_and_guidance() -> None: + """Every YAML entry is a mapping with only `message` and `guidance` strings.""" + messages = cloud_errors._messages() + + assert set(messages) > {"status_fallbacks"} + for key, entry in messages.items(): + if key == "status_fallbacks": + for status, fallback in entry.items(): + assert set(fallback) == {"message", "guidance"} + assert isinstance(fallback["message"], str) and fallback["message"] + assert isinstance(fallback["guidance"], str) and fallback["guidance"] + continue + assert set(entry) == {"message", "guidance"} + assert isinstance(entry["message"], str) and entry["message"] + assert isinstance(entry["guidance"], str) and entry["guidance"] From c5535d8a36393f03cb28b97e573ad081a280f5f8 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 04:41:53 +0000 Subject: [PATCH 14/22] fix(mcp): show only known Cloud error types in agent messages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_util/cloud_errors.py | 5 +- airbyte/_util/cloud_errors.yaml | 214 ++++++++++++------------ airbyte/mcp/_error_handling.py | 2 +- tests/unit_tests/test_cloud_api_util.py | 58 +++++++ 4 files changed, 170 insertions(+), 109 deletions(-) diff --git a/airbyte/_util/cloud_errors.py b/airbyte/_util/cloud_errors.py index 041cf0832..3d859b474 100644 --- a/airbyte/_util/cloud_errors.py +++ b/airbyte/_util/cloud_errors.py @@ -161,6 +161,9 @@ def describe_cloud_error(problem: CloudErrorInfo) -> tuple[str, str]: message, guidance = _status_fallback_message(problem.status_code) else: message, guidance = entry["message"], entry["guidance"] + # `key` is raw `type`/`title` text and can quote caller input, so it is + # shown only when it matched a fixed message row. + known_key = entry is not None if problem.key == "resource-not-found" and problem.resource_type: message = f"The {problem.resource_type} was not found." elif problem.key == "workspace-limit-for-organization-reached" and problem.limit is not None: @@ -168,7 +171,7 @@ def describe_cloud_error(problem: CloudErrorInfo) -> tuple[str, str]: if problem.status_code is not None: message += ( f" (Cloud error: {problem.key}, HTTP {problem.status_code})" - if problem.key + if known_key else f" (HTTP {problem.status_code})" ) if ( diff --git a/airbyte/_util/cloud_errors.yaml b/airbyte/_util/cloud_errors.yaml index 6ba5d7d48..6f2ec4fc8 100644 --- a/airbyte/_util/cloud_errors.yaml +++ b/airbyte/_util/cloud_errors.yaml @@ -1,273 +1,273 @@ # Agent-facing messages for Airbyte Cloud problem types. # Keys are normalized problem types (see `_problem_key` in cloud_errors.py). auth/sso-required: - guidance: Ask the user to sign in through SSO. message: This email domain must sign in with SSO. + guidance: Ask the user to sign in through SSO. auth/user-already-exists: - guidance: Use the existing one or pick another name; don't retry as is. message: A resource with this name or membership already exists. + guidance: Use the existing one or pick another name; don't retry as is. bad-request: - guidance: Check the arguments against the tool description. message: Airbyte Cloud rejected the request as invalid. + guidance: Check the arguments against the tool description. basic-schedule-validation/under-one-hour-not-allowed: - guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. message: The connection schedule is invalid. + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. billing/insufficient-credit-balance: - guidance: Ask the user to resolve billing; don't retry. message: The organization's payment status or credit balance doesn't allow this. -billing/insufficient-payment-status: guidance: Ask the user to resolve billing; don't retry. +billing/insufficient-payment-status: message: The organization's payment status or credit balance doesn't allow this. + guidance: Ask the user to resolve billing; don't retry. billing/no-active-subscription: - guidance: Ask the user to check billing; don't retry. message: This organization needs an active Airbyte subscription for this operation. + guidance: Ask the user to check billing; don't retry. billing/no-cancelable-subscription: - guidance: Don't retry; tell the user. message: There is no subscription change of this kind to act on. -billing/no-scheduled-cancellation-subscription: guidance: Don't retry; tell the user. +billing/no-scheduled-cancellation-subscription: message: There is no subscription change of this kind to act on. -billing/no-scheduled-plan-change: guidance: Don't retry; tell the user. +billing/no-scheduled-plan-change: message: There is no subscription change of this kind to act on. + guidance: Don't retry; tell the user. billing/subscription/subscription-required: - guidance: Ask the user to check billing; don't retry. message: This organization needs an active Airbyte subscription for this operation. + guidance: Ask the user to check billing; don't retry. connection-conflicting-destination-stream: - guidance: Change the selected streams or connection settings and retry. message: The selected streams can't be used with this connection (file transfer is unsupported, or a stream collides with another connection). -connection-validation/file-transfer/connection-unsupported: guidance: Change the selected streams or connection settings and retry. +connection-validation/file-transfer/connection-unsupported: message: The selected streams can't be used with this connection (file transfer is unsupported, or a stream collides with another connection). -connection-validation/file-transfer/stream-unsupported: guidance: Change the selected streams or connection settings and retry. +connection-validation/file-transfer/stream-unsupported: message: The selected streams can't be used with this connection (file transfer is unsupported, or a stream collides with another connection). + guidance: Change the selected streams or connection settings and retry. connection/destination-catalog/invalid-additional-field: - guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. message: The destination catalog settings for a stream are invalid. -connection/destination-catalog/invalid-operation: guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. +connection/destination-catalog/invalid-operation: message: The destination catalog settings for a stream are invalid. -connection/destination-catalog/invalid-primary-key: guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. +connection/destination-catalog/invalid-primary-key: message: The destination catalog settings for a stream are invalid. -connection/destination-catalog/missing-object-name: guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. +connection/destination-catalog/missing-object-name: message: The destination catalog settings for a stream are invalid. -connection/destination-catalog/missing-primary-key: guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. +connection/destination-catalog/missing-primary-key: message: The destination catalog settings for a stream are invalid. -connection/destination-catalog/missing-required-field: guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. +connection/destination-catalog/missing-required-field: message: The destination catalog settings for a stream are invalid. -connection/destination-catalog/required: guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. +connection/destination-catalog/required: message: The destination catalog settings for a stream are invalid. + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. connection/locked: - guidance: Don't retry; ask the user. message: The connection is locked and can't be changed right now. + guidance: Don't retry; ask the user. connector-rollout/invalid-request: - guidance: Don't retry; tell the user. message: The connector rollout request was rejected. -connector-rollout/not-enough-actors: guidance: Don't retry; tell the user. +connector-rollout/not-enough-actors: message: The connector rollout request was rejected. -connector-rollout/rollout-percentage-reached: guidance: Don't retry; tell the user. +connector-rollout/rollout-percentage-reached: message: The connector rollout request was rejected. + guidance: Don't retry; tell the user. cron-validation/invalid-expression: - guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. message: The connection schedule is invalid. -cron-validation/invalid-timezone: guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. +cron-validation/invalid-timezone: message: The connection schedule is invalid. -cron-validation/missing-component: guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. +cron-validation/missing-component: message: The connection schedule is invalid. -cron-validation/missing-cron-data: guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. +cron-validation/missing-cron-data: message: The connection schedule is invalid. -cron-validation/under-one-hour-not-allowed: guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. +cron-validation/under-one-hour-not-allowed: message: The connection schedule is invalid. -cron-validation/unsupported-timezone: guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. +cron-validation/unsupported-timezone: message: The connection schedule is invalid. + guidance: Fix the schedule (cron expression, timezone, or frequency) and retry. dataplane-group-name-already-exists: - guidance: Use the existing one or pick another name; don't retry as is. message: A resource with this name or membership already exists. -dataplane-name-already-exists: guidance: Use the existing one or pick another name; don't retry as is. +dataplane-name-already-exists: message: A resource with this name or membership already exists. + guidance: Use the existing one or pick another name; don't retry as is. dbtcloud/access-denied: - guidance: Ask the user to check the dbt Cloud integration. message: dbt Cloud rejected the integration credentials, requires a paid plan, or the call failed. -dbtcloud/generic: guidance: Ask the user to check the dbt Cloud integration. +dbtcloud/generic: message: dbt Cloud rejected the integration credentials, requires a paid plan, or the call failed. -dbtcloud/paid-plan-required: guidance: Ask the user to check the dbt Cloud integration. +dbtcloud/paid-plan-required: message: dbt Cloud rejected the integration credentials, requires a paid plan, or the call failed. + guidance: Ask the user to check the dbt Cloud integration. destination/catalog-not-found: - guidance: Set up the destination catalog before this operation; do not change the ID. message: This connection has no destination catalog yet. + guidance: Set up the destination catalog before this operation; do not change the ID. destination/discover-not-supported: - guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. message: The destination catalog settings for a stream are invalid. + guidance: Fix the stream's destination settings (object name, sync mode, fields, primary key) and retry. embedded/endpoint-moved: - guidance: Don't retry. message: This operation isn't available at this Airbyte endpoint. + guidance: Don't retry. entitlement-service/error-adding-organization: - guidance: Wait and retry once. message: Airbyte Cloud is temporarily unavailable. + guidance: Wait and retry once. entitlement-service/invalid-organization-state: - guidance: Don't retry; tell the user. message: Airbyte Cloud failed to complete this operation. + guidance: Don't retry; tell the user. failed-precondition: - guidance: Check the resource's state; ask the user if unclear. message: A required condition for this operation isn't met. + guidance: Check the resource's state; ask the user if unclear. generate-contribution: - guidance: Don't retry; tell the user. message: Airbyte Cloud failed to complete this operation. + guidance: Don't retry; tell the user. generate-contribution/connector-image-name-in-use: - guidance: Use the existing one or pick another name; don't retry as is. message: A resource with this name or membership already exists. + guidance: Use the existing one or pick another name; don't retry as is. generate-contribution/insufficient-github-token-permissions: - guidance: Ask the user for a valid token. message: The GitHub token is invalid or lacks repo write permission. -generate-contribution/invalid-github-token: guidance: Ask the user for a valid token. +generate-contribution/invalid-github-token: message: The GitHub token is invalid or lacks repo write permission. + guidance: Ask the user for a valid token. group-already-exists: - guidance: Use the existing one or pick another name; don't retry as is. message: A resource with this name or membership already exists. + guidance: Use the existing one or pick another name; don't retry as is. group-managed-by-scim: - guidance: Don't retry; tell the user. message: This group is managed by SCIM and can't be changed here. + guidance: Don't retry; tell the user. group-member-already-exists: - guidance: Use the existing one or pick another name; don't retry as is. message: A resource with this name or membership already exists. -group-permission-already-exists: guidance: Use the existing one or pick another name; don't retry as is. +group-permission-already-exists: message: A resource with this name or membership already exists. + guidance: Use the existing one or pick another name; don't retry as is. implementation/not-implemented-in-oss: - guidance: Don't retry. message: This operation isn't available at this Airbyte endpoint. + guidance: Don't retry. invalid-api-key: - guidance: Ask the user to check the client ID/secret or token; don't retry. message: Airbyte Cloud rejected the credentials. + guidance: Ask the user to check the client ID/secret or token; don't retry. invalid-consent-url: - guidance: Don't retry; tell the user. message: Airbyte Cloud failed to complete this operation. + guidance: Don't retry; tell the user. invalid-redirect-url: - guidance: Fix the configuration and retry. message: The redirect URL is not a valid HTTPS URL. + guidance: Fix the configuration and retry. license/entitlement: - guidance: Ask the user; don't retry. message: The organization's plan doesn't allow this. + guidance: Ask the user; don't retry. mapper-validation: - guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. message: The mapper configuration is invalid. -mapper-validation/invalid-config: guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. +mapper-validation/invalid-config: message: The mapper configuration is invalid. -mapper-validation/missing-required-param: guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. +mapper-validation/missing-required-param: message: The mapper configuration is invalid. + guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. mapper-validation/runtime-secrets-manager-required: - guidance: Ask the user. message: This needs a runtime secrets manager configured. + guidance: Ask the user. mapper-validation/secret-not-found: - guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. message: The mapper configuration is invalid. + guidance: Fix the mapper configuration and retry. If a mapper secret is missing, resend the secret values. notification/config/missing-url: - guidance: Fix the input and retry. message: Airbyte Cloud rejected a value in the request. -notification/config/required: guidance: Fix the input and retry. +notification/config/required: message: Airbyte Cloud rejected a value in the request. + guidance: Fix the input and retry. oauth-callback-failure: - guidance: Don't retry; tell the user. message: Airbyte Cloud failed to complete this operation. + guidance: Don't retry; tell the user. request-timeout-exceeded: - guidance: Check the resource's current state before retrying; don't blindly retry a create. message: Airbyte Cloud timed out. The change may or may not have been applied. + guidance: Check the resource's current state before retrying; don't blindly retry a create. resource-not-found: - guidance: Check the ID; list the resources to find the right one. message: The resource was not found. + guidance: Check the ID; list the resources to find the right one. service-unavailable: - guidance: Wait and retry once. message: Airbyte Cloud is temporarily unavailable. + guidance: Wait and retry once. sso-activation: - guidance: Don't retry; tell the user. message: Airbyte Cloud failed to complete this operation. -sso-config-retrieval: guidance: Don't retry; tell the user. +sso-config-retrieval: message: Airbyte Cloud failed to complete this operation. -sso-credential-update: guidance: Don't retry; tell the user. +sso-credential-update: message: Airbyte Cloud failed to complete this operation. -sso-deletion: guidance: Don't retry; tell the user. +sso-deletion: message: Airbyte Cloud failed to complete this operation. -sso-setup: guidance: Don't retry; tell the user. +sso-setup: message: Airbyte Cloud failed to complete this operation. + guidance: Don't retry; tell the user. sso-token-validation: - guidance: Ask the user to sign in again. message: The SSO access token is invalid or expired. + guidance: Ask the user to sign in again. state-conflict: - guidance: Check the resource's current status before retrying. message: The resource is in a state that doesn't allow this operation (for example, a job is running or already finished). -status_fallbacks: - 401: - guidance: Ask the user to check the client ID/secret or token; don't retry. - message: Airbyte Cloud rejected the credentials. - 404: - guidance: Check the ID; list the resources to find the right one. - message: The resource was not found. - 408: - guidance: Check the resource's current state before retrying; don't blindly retry a create. - message: Airbyte Cloud timed out. The change may or may not have been applied. - 409: - guidance: Check the resource's current status before retrying. - message: The resource is in a state that doesn't allow this operation (for example, a job is running or already finished). - 429: - guidance: Stop calling Airbyte Cloud tools and tell the user; don't retry. - message: Airbyte Cloud is refusing requests from these credentials (rate limited). - 4xx: - guidance: Check the arguments against the tool description. - message: Airbyte Cloud rejected the request as invalid. - 5xx: - guidance: Check the arguments (ranges, IDs, formats). If they look right, don't retry more than once; tell the user. - message: Airbyte Cloud hit an unexpected error. This is often caused by an invalid argument value. - default: - guidance: Retry at most once; tell the user if it persists. - message: Airbyte Cloud returned an error. + guidance: Check the resource's current status before retrying. tag-already-exists: - guidance: Use the existing one or pick another name; don't retry as is. message: A resource with this name or membership already exists. + guidance: Use the existing one or pick another name; don't retry as is. tag-invalid-hex-color: - guidance: Fix the input and retry. message: Airbyte Cloud rejected a value in the request. + guidance: Fix the input and retry. tag-limit-for-workspace-reached: - guidance: Delete a tag first or ask the user. message: The workspace has reached its tag limit. + guidance: Delete a tag first or ask the user. tag-name-too-long: - guidance: Fix the input and retry. message: Airbyte Cloud rejected a value in the request. + guidance: Fix the input and retry. try-again-later: - guidance: Wait and retry once. message: Airbyte Cloud is busy with a conflicting change. + guidance: Wait and retry once. unexpected-problem: - guidance: Check the arguments (ranges, IDs, formats). If they look right, don't retry more than once; tell the user. message: Airbyte Cloud hit an unexpected error. This is often caused by an invalid argument value. + guidance: Check the arguments (ranges, IDs, formats). If they look right, don't retry more than once; tell the user. unprocessable-entity: - guidance: Fix the configuration and retry. message: The request body or connector configuration failed validation. + guidance: Fix the configuration and retry. value-not-found: - guidance: Check the name against the connector list (e.g. `source-postgres`). message: Airbyte doesn't recognize the connector type name. + guidance: Check the name against the connector list (e.g. `source-postgres`). workspace-limit-for-organization-reached: - guidance: Ask the user; don't retry. message: The organization's plan doesn't allow this. + guidance: Ask the user; don't retry. +status_fallbacks: + 401: + message: Airbyte Cloud rejected the credentials. + guidance: Ask the user to check the client ID/secret or token; don't retry. + 404: + message: The resource was not found. + guidance: Check the ID; list the resources to find the right one. + 408: + message: Airbyte Cloud timed out. The change may or may not have been applied. + guidance: Check the resource's current state before retrying; don't blindly retry a create. + 409: + message: The resource is in a state that doesn't allow this operation (for example, a job is running or already finished). + guidance: Check the resource's current status before retrying. + 429: + message: Airbyte Cloud is refusing requests from these credentials (rate limited). + guidance: Stop calling Airbyte Cloud tools and tell the user; don't retry. + 4xx: + message: Airbyte Cloud rejected the request as invalid. + guidance: Check the arguments against the tool description. + 5xx: + message: Airbyte Cloud hit an unexpected error. This is often caused by an invalid argument value. + guidance: Check the arguments (ranges, IDs, formats). If they look right, don't retry more than once; tell the user. + default: + message: Airbyte Cloud returned an error. + guidance: Retry at most once; tell the user if it persists. diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index 95530941a..2bf547054 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -235,7 +235,7 @@ def redact_agent_text( else: text = re.sub( rf"(? None: assert set(entry) == {"message", "guidance"} assert isinstance(entry["message"], str) and entry["message"] assert isinstance(entry["guidance"], str) and entry["guidance"] + + +@pytest.mark.parametrize( + ("status_code", "body", "absent", "suffix"), + [ + pytest.param( + 500, + _problem_body( + "https://reference.airbyte.com/reference/errors", + title="password=hunter2-secret-value", + ), + ("hunter2", "password"), + "(HTTP 500)", + id="unknown-title-never-relays", + ), + pytest.param( + 500, + _problem_body("error:some/unknown-thing contains sk_live_abc"), + ("sk_live", "unknown-thing"), + "(HTTP 500)", + id="unknown-type-never-relays", + ), + pytest.param( + 500, + _problem_body( + "https://reference.airbyte.com/reference/errors", + title="unexpected-problem", + ), + (), + "(Cloud error: unexpected-problem, HTTP 500)", + id="known-key-shown", + ), + pytest.param( + None, + _problem_body("error:some/unknown-thing contains sk_live_abc"), + ("sk_live", "unknown-thing"), + None, + id="unknown-key-no-status-no-suffix", + ), + ], +) +def test_describe_cloud_error_suffix_only_for_known_keys( + status_code: int | None, + body: str, + absent: tuple[str, ...], + suffix: str | None, +) -> None: + """An unmatched `type`/`title` is never echoed into agent-facing text.""" + problem = cloud_errors.parse_cloud_error(status_code, body) + message, guidance = cloud_errors.describe_cloud_error(problem) + + for needle in absent: + assert needle not in message + assert needle not in guidance + if suffix is None: + assert "(Cloud error:" not in message and "(HTTP" not in message + else: + assert message.endswith(suffix) From bfc33b5cbfc57b32db6b95c736d9e677339a43f1 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 04:48:41 +0000 Subject: [PATCH 15/22] docs(mcp): document how to maintain Cloud error messages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 5 +++++ CONTRIBUTING.md | 9 +++++++++ airbyte/_util/cloud_errors.yaml | 19 +++++++++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 041bb9b05..8677111d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e96624084..0373d1bec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/airbyte/_util/cloud_errors.yaml b/airbyte/_util/cloud_errors.yaml index 6f2ec4fc8..3bafd3deb 100644 --- a/airbyte/_util/cloud_errors.yaml +++ b/airbyte/_util/cloud_errors.yaml @@ -1,5 +1,20 @@ -# Agent-facing messages for Airbyte Cloud problem types. -# Keys are normalized problem types (see `_problem_key` in cloud_errors.py). +# Agent-facing messages for Airbyte Cloud problem types, used by +# `describe_cloud_error` in cloud_errors.py. +# +# Maintaining this file: +# - Keys are normalized problem types: the bare path, like `connection/locked`, +# without the `https://reference.airbyte.com/reference/errors` URL, `#`, +# `error:` or `409-` prefix (see `_problem_key`). Keep keys sorted. +# - Each entry has exactly `message` (what went wrong) and `guidance` (one +# recovery step: retry later, fix input, ask the user, or don't retry). +# - Write fixed text only. Never copy in Cloud's `detail`, `data.message` or +# other body fields; they can quote caller secrets or server internals. +# - Problem types missing here fall back to `status_fallbacks` by HTTP status, +# and the raw type is not shown to the agent. +# - When adding a type, also add it to +# `test_every_published_problem_type_matches_a_fixed_message`. +# `test_cloud_errors_yaml_entries_have_message_and_guidance` checks the shape. +# - 403 and actor-not-ready are handled in api_util.py, not here. auth/sso-required: message: This email domain must sign in with SSO. guidance: Ask the user to sign in through SSO. From b20f1c5578a76256ef012757473ae201e9e626b0 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 05:10:11 +0000 Subject: [PATCH 16/22] fix(mcp): align agent error handling and API boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_util/api_util.py | 7 +++++++ airbyte/mcp/_error_handling.py | 9 +++++---- tests/unit_tests/test_mcp_connection_schedules.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/airbyte/_util/api_util.py b/airbyte/_util/api_util.py index 16d5d4d04..2a8ac858b 100644 --- a/airbyte/_util/api_util.py +++ b/airbyte/_util/api_util.py @@ -172,6 +172,13 @@ def error_response_body(error: BaseException) -> str | 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: diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index 2bf547054..cb4d4f287 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -9,14 +9,13 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any -from airbyte_api.errors import SDKError from fastmcp.exceptions import ToolError, ValidationError from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp_extensions.otel._arg_digests import classify_tool # noqa: PLC2701 from fastmcp_extensions.otel._extras import _chain, declared_parameters # noqa: PLC2701 from fastmcp_extensions.otel.models import TraceArg -from airbyte._util.api_util import error_response_body +from airbyte._util.api_util import error_response_body, sdk_error_response from airbyte._util.cloud_errors import describe_cloud_error, parse_cloud_error from airbyte.exceptions import ( AirbyteAgentsUnavailableError, @@ -98,8 +97,10 @@ def agent_error_text(error: ToolError) -> str: cause = error.__cause__ if isinstance(cause, AirbyteLibError): return format_user_facing_error(cause) - if isinstance(cause, SDKError): - message, guidance = describe_cloud_error(parse_cloud_error(cause.status_code, cause.body)) + sdk_response = sdk_error_response(cause) if cause is not None else None + if sdk_response is not None: + status_code, body = sdk_response + message, guidance = describe_cloud_error(parse_cloud_error(status_code, body)) return f"{message} {guidance}" return str(error) diff --git a/tests/unit_tests/test_mcp_connection_schedules.py b/tests/unit_tests/test_mcp_connection_schedules.py index 07ceeac51..d3addc411 100644 --- a/tests/unit_tests/test_mcp_connection_schedules.py +++ b/tests/unit_tests/test_mcp_connection_schedules.py @@ -309,5 +309,5 @@ def test_update_interval_api_failure_is_reported( status=400, ) - with pytest.raises(ToolError, match="status 400"): + with pytest.raises(ToolError, match="HTTP 400"): asyncio.run(_update(server, interval_hours=24)) From 0831292f00da29ea9a3060264da821bb1e09cca2 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 05:38:27 +0000 Subject: [PATCH 17/22] chore(deps): repin fastmcp-extensions prerelease Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f8729be09..7bf3b81ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", # TK-TODO: Needs a release and re-pin before merging - "fastmcp-extensions==0.34.0.dev14838020136172", + "fastmcp-extensions==0.34.0.dev14838027854072", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/uv.lock b/uv.lock index 92051b4b3..7debbf0a9 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = "==0.34.0.dev14838020136172" }, + { name = "fastmcp-extensions", specifier = "==0.34.0.dev14838027854072" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.34.0.dev14838020136172" +version = "0.34.0.dev14838027854072" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/ae/539451bddee43df494e055015a4e2dcb84dba79c21cf54501c8776485044/fastmcp_extensions-0.34.0.dev14838020136172.tar.gz", hash = "sha256:0fc3d2b27a798f060ad58dab5dfa8bb5bed5a835824f760a35d73c854559c5d4", size = 361509, upload-time = "2026-10-10T03:19:45.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/44/9938494cd571d70db69fe82509cfb5620de19333dca441c819502b916437/fastmcp_extensions-0.34.0.dev14838027854072.tar.gz", hash = "sha256:a6b1c6197e2ccee9f084a4fd5c6b85d2a5b7f6be5c3aa3f8b950f13c7358258e", size = 362738, upload-time = "2026-10-10T05:35:32.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/8a/f788ad5630c3ee334dad83ec0e0a17e309badc34793e71ec53b4a6920410/fastmcp_extensions-0.34.0.dev14838020136172-py3-none-any.whl", hash = "sha256:c55626692eeb07b73b754c0ed3482f86d14bd73178d879cd1ce2d63f0c7aeccf", size = 161830, upload-time = "2026-10-10T03:19:43.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b3/8342ce5ab7f342c98517745e6b58c09b5b7a17a20ade71f8f781c563d055/fastmcp_extensions-0.34.0.dev14838027854072-py3-none-any.whl", hash = "sha256:1449999021471f4119a204ca77fc55c09288df8fceb9a39aca6fef16e6ff8c71", size = 162377, upload-time = "2026-10-10T05:35:31.291Z" }, ] [[package]] From 132b7db50bb20fa09c925412bcdd500d1149e87f Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 05:38:30 +0000 Subject: [PATCH 18/22] fix(mcp): tag raised tool-call errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/mcp/_datadog.py | 2 ++ tests/unit_tests/test_mcp_datadog.py | 35 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/airbyte/mcp/_datadog.py b/airbyte/mcp/_datadog.py index f4cc8af21..f8c846732 100644 --- a/airbyte/mcp/_datadog.py +++ b/airbyte/mcp/_datadog.py @@ -748,6 +748,8 @@ async def __call__( # noqa: PLR0912, PLR0915 if error := sys.exc_info()[1]: span.error = 1 _annotate_attributes(span, _exception_attributes(error)) + if tool_call: + span.set_tag("error.message", _tool_error_message(span)) span.__exit__(None, None, None) except Exception: logger.debug("Datadog span completion failed") diff --git a/tests/unit_tests/test_mcp_datadog.py b/tests/unit_tests/test_mcp_datadog.py index 3fcb2ce7e..8f0e4e1e7 100644 --- a/tests/unit_tests/test_mcp_datadog.py +++ b/tests/unit_tests/test_mcp_datadog.py @@ -13,6 +13,7 @@ import requests from airbyte_api.errors import SDKError from fastmcp.exceptions import NotFoundError, ToolError, ValidationError +from fastmcp_extensions.tool_filters import ToolUnavailableError from airbyte._util import api_util, meta from airbyte.exceptions import ( @@ -1021,7 +1022,9 @@ async def fail(_ctx): ) escaped = next(span for span in reversed(spans) if span.span_type == "llm") assert escaped.error and escaped.get_tag("error.type") == "ValueError" - assert escaped.get_tag("error.message") is None + assert escaped.get_tag("error.message") == ( + "tool resulted in an error (unclassified)" + ) escaped_stack = escaped.get_tag("error.stack") assert escaped_stack and escaped_stack.startswith("ValueError\n") assert private_payload not in escaped_stack @@ -1042,6 +1045,36 @@ async def fail(_ctx): escaped_metadata["organization_id"] == "87654321-4321-4321-4321-abcdef123456" ) + + async def unavailable_tool(_ctx): + raise ToolUnavailableError() + + with pytest.raises(ToolUnavailableError): + await _datadog._DatadogRequestMiddleware()( + SimpleNamespace( + method="tools/call", + params={"name": "blocked_tool", "arguments": {}}, + ), + unavailable_tool, + ) + unavailable = next(span for span in reversed(spans) if span.span_type == "llm") + assert unavailable.get_tag("error.message") == ( + "tool resulted in an error (tool_unavailable)" + ) + + async def fail_request(_ctx): + raise RuntimeError("private request failure") + + with pytest.raises(RuntimeError, match="private request failure"): + await _datadog._DatadogRequestMiddleware()( + SimpleNamespace(method="tools/list", params={}), + fail_request, + ) + failed_request = next( + span for span in reversed(spans) if span.span_type == "llm" + ) + assert failed_request.get_tag("error.message") is None + # Cancellation must propagate and restore the previous Datadog context. with tracer.trace("cancellation-parent") as parent: before = tracer.current_span() From 1ab4f5bf93d455f7b1afa5f52dfc00668b8f8875 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 05:56:40 +0000 Subject: [PATCH 19/22] chore(deps): pin fastmcp-extensions>=0.34.1 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- pyproject.toml | 3 +-- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7bf3b81ba..8572e3aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,8 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - # TK-TODO: Needs a release and re-pin before merging - "fastmcp-extensions==0.34.0.dev14838027854072", + "fastmcp-extensions>=0.34.1,<1.0.0", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/uv.lock b/uv.lock index 7debbf0a9..c2eda87ac 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = "==0.34.0.dev14838027854072" }, + { name = "fastmcp-extensions", specifier = ">=0.34.1,<1.0.0" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.34.0.dev14838027854072" +version = "0.34.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/44/9938494cd571d70db69fe82509cfb5620de19333dca441c819502b916437/fastmcp_extensions-0.34.0.dev14838027854072.tar.gz", hash = "sha256:a6b1c6197e2ccee9f084a4fd5c6b85d2a5b7f6be5c3aa3f8b950f13c7358258e", size = 362738, upload-time = "2026-10-10T05:35:32.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/40/5ad985b5cf5238d84eccc4a101641cce169fb73d20f591f43cbed895c045/fastmcp_extensions-0.34.1.tar.gz", hash = "sha256:734be63c580a5da6d69e2e804db9b960df07d6654f2b268ee75c79aa00e4c70b", size = 362674, upload-time = "2026-10-10T05:55:28.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/b3/8342ce5ab7f342c98517745e6b58c09b5b7a17a20ade71f8f781c563d055/fastmcp_extensions-0.34.0.dev14838027854072-py3-none-any.whl", hash = "sha256:1449999021471f4119a204ca77fc55c09288df8fceb9a39aca6fef16e6ff8c71", size = 162377, upload-time = "2026-10-10T05:35:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3e/bf4a1edcd67a33bb40bbb100bef079a775467db92b096c2d177b2c085648/fastmcp_extensions-0.34.1-py3-none-any.whl", hash = "sha256:24c11884875ecbbb26bb03547ad09c83cc11c210e6a49709dfa52cbcef1f6b94", size = 162200, upload-time = "2026-10-10T05:55:26.83Z" }, ] [[package]] From 1fc2d5f3bdfbe44f0fb2c54a4962341c52a391f8 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 06:21:35 +0000 Subject: [PATCH 20/22] fix(mcp): close remaining agent-text and telemetry gaps from review Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_util/cloud_errors.py | 35 ++++-- airbyte/mcp/_error_handling.py | 49 ++++++-- airbyte/mcp/local.py | 21 +++- tests/unit_tests/test_cloud_api_util.py | 37 +++++- tests/unit_tests/test_mcp_tool_errors.py | 107 ++++++++++++++++++ .../unit_tests/test_mcp_trusted_execution.py | 21 ++++ 6 files changed, 244 insertions(+), 26 deletions(-) diff --git a/airbyte/_util/cloud_errors.py b/airbyte/_util/cloud_errors.py index 3d859b474..3a13088a2 100644 --- a/airbyte/_util/cloud_errors.py +++ b/airbyte/_util/cloud_errors.py @@ -27,11 +27,31 @@ # A slug names a kind of problem; one that is or holds an ID names a resource. _ID_LIKE = re.compile(r"\d+|.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*") _MAX_PROBLEM_BODY = 65536 -_RESOURCE_TYPE = re.compile(r"[a-z_-]{1,40}") +_RESOURCE_TYPES = frozenset( + { + "source", + "destination", + "connection", + "workspace", + "organization", + "job", + "user", + "permission", + "source_definition", + "destination_definition", + } +) _UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") _PROBLEM_TYPE_PREFIX = "https://reference.airbyte.com/reference/errors" +def is_valid_problem_slug(value: object) -> bool: + """Return whether a problem slug is safe to export.""" + return isinstance(value, str) and bool( + _PROBLEM_SLUG.fullmatch(value) and not _ID_LIKE.fullmatch(value) + ) + + @dataclass(frozen=True) class CloudErrorInfo: """The allow-listed fields of an Airbyte Cloud error body. @@ -59,15 +79,16 @@ def slug_of(value: object) -> str | None: if not isinstance(value, str): return None slug = re.split(r"[/#]", value)[-1] - if _PROBLEM_SLUG.fullmatch(slug) and not _ID_LIKE.fullmatch(slug): + if is_valid_problem_slug(slug): return slug return None - slug = slug_of(problem.get("type")) + problem_type = problem.get("type") + if not isinstance(problem_type, str): + return None + slug = slug_of(problem_type) if slug is None: - if isinstance(problem.get("type"), str): - return None - return slug_of(problem.get("title")) + return None if slug == _GENERIC_PROBLEM_SLUG: return slug_of(problem.get("title")) return slug @@ -115,7 +136,7 @@ def parse_cloud_error(status_code: int | None, body: str | None) -> CloudErrorIn data = problem.get("data") if isinstance(data, dict): candidate = data.get("resourceType") - if isinstance(candidate, str) and _RESOURCE_TYPE.fullmatch(candidate): + if isinstance(candidate, str) and candidate in _RESOURCE_TYPES: resource_type = candidate limit_value = data.get("limit") if isinstance(limit_value, int) and not isinstance(limit_value, bool): diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index cb4d4f287..9bea94632 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -16,9 +16,14 @@ from fastmcp_extensions.otel.models import TraceArg from airbyte._util.api_util import error_response_body, sdk_error_response -from airbyte._util.cloud_errors import describe_cloud_error, parse_cloud_error +from airbyte._util.cloud_errors import ( + describe_cloud_error, + is_valid_problem_slug, + parse_cloud_error, +) from airbyte.exceptions import ( AirbyteAgentsUnavailableError, + AirbyteCloudApiError, AirbyteConnectionSyncError, AirbyteConnectionSyncTimeoutError, AirbyteConnectorInUseError, @@ -55,8 +60,19 @@ """Expected errors returned to MCP clients as concise message and guidance text.""" +def _cloud_api_error_text(error: AirbyteCloudApiError) -> str: + """Return fixed Cloud error text, retaining direct-access guidance when present.""" + message, table_guidance = describe_cloud_error( + parse_cloud_error(error.status_code, error_response_body(error)) + ) + guidance = error.guidance if error.guidance is not None else table_guidance + return f"{message} {guidance}".rstrip() + + def format_user_facing_error(error: BaseException) -> str: """Return the error message followed by its guidance, when present.""" + if isinstance(error, AirbyteCloudApiError): + return _cloud_api_error_text(error) if not isinstance(error, AirbyteLibError): return str(error) text = error.get_message() @@ -89,12 +105,21 @@ def mcp_tool_error_reason(error: BaseException) -> str | None: (body for body in map(error_response_body, _chain(error)) if body is not None), None, ) - return parse_cloud_error(None, body).slug + if body is not None: + return parse_cloud_error(None, body).slug + for cause in _chain(error): + if isinstance(cause, AirbyteLibError): + problem_type = (cause.context or {}).get("problem_type") + if isinstance(problem_type, str): + return problem_type if is_valid_problem_slug(problem_type) else None + return None def agent_error_text(error: ToolError) -> str: """Return the text an agent sees for a tool error before redaction.""" cause = error.__cause__ + if isinstance(cause, AirbyteCloudApiError): + return _cloud_api_error_text(cause) if isinstance(cause, AirbyteLibError): return format_user_facing_error(cause) sdk_response = sdk_error_response(cause) if cause is not None else None @@ -181,17 +206,17 @@ def _allowed_choices( tool: Tool | None, name: str, arg_class: Any, # noqa: ANN401 -) -> frozenset[Any]: +) -> tuple[Any, ...]: """Return the declared `enum`/`const` choices of a parameter, if any.""" if tool is not None: schema = declared_parameters(tool).get(name) if isinstance(schema, Mapping): if isinstance(schema.get("enum"), list): - return frozenset(schema["enum"]) + return tuple(schema["enum"]) if "const" in schema: - return frozenset({schema["const"]}) + return (schema["const"],) allowed = getattr(arg_class, "allowed", None) - return frozenset(allowed) if allowed else frozenset() + return tuple(allowed) if allowed is not None else () def redact_agent_text( @@ -203,10 +228,10 @@ def redact_agent_text( Each parameter is classified by the same rules arg tracing uses, so error text never shows a value tracing would not record in clear: secret-class - parameters lose every leaf, other parameters lose leaves of eight or - more characters, and `VALUE` (enum-like) parameters stay clear only when - they hold one of the declared choices. Non-secret leaves are masked as - whole tokens only, so common words in guidance text stay untouched. + parameters lose every leaf, other parameters lose leaves of eight or more + characters, and `VALUE` (enum-like) parameters stay clear only when they + hold one of the declared choices. Secret leaves shorter than four + characters and non-secret leaves are masked as whole tokens. """ if arguments: func = getattr(tool, "fn", None) if tool is not None else None @@ -219,7 +244,7 @@ def redact_agent_text( arg_class = classes.get(name) mode = arg_class.mode if arg_class is not None else TraceArg.PRESENCE if mode is TraceArg.VALUE: - if value in _allowed_choices(tool, name, arg_class): + if any(value == choice for choice in _allowed_choices(tool, name, arg_class)): continue secret = False else: @@ -231,7 +256,7 @@ def redact_agent_text( continue seen.add(leaf) placeholder = f"" - if secret: + if secret and len(leaf) >= 4: # noqa: PLR2004 text = text.replace(leaf, placeholder) else: text = re.sub( diff --git a/airbyte/mcp/local.py b/airbyte/mcp/local.py index a19ed019e..cee0f6d49 100644 --- a/airbyte/mcp/local.py +++ b/airbyte/mcp/local.py @@ -18,6 +18,7 @@ from fastmcp import FastMCP from fastmcp_extensions import TraceArg, register_mcp_tools +from fastmcp_extensions.otel._extras import error_stack # noqa: PLC2701 from pydantic import BaseModel, Field from airbyte import get_source @@ -509,7 +510,12 @@ def read_source_stream_records( print(f"Retrieved {len(records)} records from stream '{stream_name}'", sys.stderr) except Exception as ex: - logger.exception("Error reading records from source '%s'", source_connector_name) + logger.warning( + "Error reading records from source '%s' (%s)\n%s", + source_connector_name, + type(ex).__name__, + error_stack(ex) or "", + ) # If any error occurs, we print the error message to stderr and return an empty list. return ( f"Error reading records from source '{source_connector_name}': " @@ -623,7 +629,12 @@ def get_stream_previews( on_error="ignore", ) except Exception as ex: - logger.exception("Error getting stream previews from source '%s'", source_name) + logger.warning( + "Error getting stream previews from source '%s' (%s)\n%s", + source_name, + type(ex).__name__, + error_stack(ex) or "", + ) return { "ERROR": f"Error getting stream previews from source '{source_name}': " f"{format_user_facing_error(ex)}" @@ -875,7 +886,11 @@ def run_sql_query( max_records=max_records, ) except Exception as ex: - logger.exception("Error running SQL query") + logger.warning( + "Error running SQL query (%s)\n%s", + type(ex).__name__, + error_stack(ex) or "", + ) return [ { "ERROR": f"Error running SQL query: {format_user_facing_error(ex)}", diff --git a/tests/unit_tests/test_cloud_api_util.py b/tests/unit_tests/test_cloud_api_util.py index 3ceef6a19..093690de3 100644 --- a/tests/unit_tests/test_cloud_api_util.py +++ b/tests/unit_tests/test_cloud_api_util.py @@ -3306,24 +3306,53 @@ def test_every_published_problem_type_matches_a_fixed_message( ), pytest.param( {"title": "unexpected-problem"}, + None, "unexpected-problem", - "unexpected-problem", - id="missing-type-reads-title", + id="missing-type-does-not-set-slug", + ), + pytest.param( + {"title": "password=hunter2"}, + None, + "password=hunter2", + id="missing-type-does-not-set-untrusted-slug", + ), + pytest.param( + {"type": 123, "title": "try-again-later"}, + None, + "try-again-later", + id="non-string-type-does-not-set-slug", ), ], ) def test_parse_cloud_error_title_fallback_only_for_generic_type( - problem: dict[str, str], + problem: dict[str, Any], expected_slug: str | None, expected_key: str | None, ) -> None: - """`title` is read only when `type` is absent or the generic errors URL.""" + """Only a generic string `type` permits a title slug.""" parsed = cloud_errors.parse_cloud_error(None, json.dumps(problem)) assert parsed.slug == expected_slug assert parsed.key == expected_key +def test_resource_type_in_agent_text_must_be_allowlisted() -> None: + body = _problem_body( + "https://reference.airbyte.com/reference/errors#resource-not-found", + data={"resourceType": "secretvalue"}, + ) + problem = cloud_errors.parse_cloud_error(404, body) + + message, _ = cloud_errors.describe_cloud_error(problem) + + assert problem.resource_type is None + assert ( + message + == "The resource was not found. (Cloud error: resource-not-found, HTTP 404)" + ) + assert "secretvalue" not in message + + def test_cloud_errors_yaml_entries_have_message_and_guidance() -> None: """Every YAML entry is a mapping with only `message` and `guidance` strings.""" messages = cloud_errors._messages() diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index 7865367b9..ea70b57ef 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -27,6 +27,7 @@ from airbyte.mcp._error_handling import ( MCP_TOOL_USER_FACING_ERRORS, AgentErrorTextMiddleware, + agent_error_text, redact_agent_text, classify_mcp_tool_error, format_user_facing_error, @@ -122,6 +123,52 @@ def test_format_user_facing_error() -> None: assert format_user_facing_error(ValueError("plain")) == "plain" +def test_direct_cloud_api_error_uses_fixed_table_message_in_agent_text() -> None: + error = AirbyteCloudApiError( + status_code=404, + message="Request failed at https://config.example/connections/secret-id", + context={ + "full_url": "https://config.example/connections/secret-id", + "response_text": json.dumps({ + "type": "https://reference.airbyte.com/reference/errors#resource-not-found", + "data": { + "resourceType": "connection", + "message": "SENTINEL_RESPONSE_TEXT", + }, + }), + }, + ) + with pytest.raises(ToolError) as exc_info: + raise ToolError("unformatted error") from error + + text = agent_error_text(exc_info.value) + + assert "The connection was not found." in text + assert "resource-not-found, HTTP 404" in text + assert "https://config.example" not in text + assert "SENTINEL_RESPONSE_TEXT" not in text + assert "response_text" not in text + + +def test_format_direct_cloud_api_error_keeps_fixed_forbidden_guidance() -> None: + guidance = ( + "Authentication succeeded but access was denied; the workspace or connector " + "may not be enabled for agent access in Airbyte Cloud." + ) + error = AirbyteCloudApiError( + status_code=403, + message="Request failed at https://config.example/connections/secret-id", + guidance=guidance, + context={"full_url": "https://config.example/connections/secret-id"}, + ) + + text = format_user_facing_error(error) + + assert guidance in text + assert "https://config.example" not in text + assert "full_url" not in text + + @pytest.mark.parametrize( ("error", "expected"), [ @@ -181,6 +228,24 @@ def test_mcp_tool_error_reason_is_never_an_id( assert mcp_tool_error_reason(error) == expected +def test_mcp_tool_error_reason_uses_validated_context_problem_type() -> None: + error = AirbyteCloudApiError(context={"problem_type": "try-again-later"}) + error.__cause__ = requests.HTTPError("request failed") + assert mcp_tool_error_reason(error) == "try-again-later" + + +@pytest.mark.parametrize( + "problem_type", + [ + "97953b90-8f3a-4c1e-9d2b-0a1b2c3d4e5f", + "password=hunter2", + ], +) +def test_mcp_tool_error_reason_rejects_invalid_context_slug(problem_type: str) -> None: + error = AirbyteCloudApiError(context={"problem_type": problem_type}) + assert mcp_tool_error_reason(error) is None + + def _server_with_agent_error_text(): """Build a test server wired like `airbyte.mcp.server.app`.""" server = mcp_server( @@ -239,6 +304,26 @@ def with_mode(mode: Literal["full", "incremental"]) -> str: assert "input_value" not in text +@pytest.mark.parametrize( + "value", + [ + pytest.param(["a"], id="list"), + pytest.param({"k": "v"}, id="dict"), + ], +) +def test_unhashable_literal_argument_is_redacted(value: object) -> None: + server = _server_with_agent_error_text() + + @server.tool + def with_mode(mode: Literal["a"]) -> str: + return str(mode) + + text = _call_error(server, "with_mode", {"mode": value}) + + assert repr(value) not in text + assert "input_value" not in text + + def test_cloud_error_is_formatted_without_debug_context() -> None: server = _server_with_agent_error_text() @@ -376,3 +461,25 @@ def test_redact_agent_text_masks_whole_tokens_only( arguments: dict, text: str, expected: str ) -> None: assert redact_agent_text(text, arguments, _tool_stub(_classify_args)) == expected + + +def test_short_secret_is_masked_as_a_whole_token() -> None: + assert ( + redact_agent_text( + "Ask the user for a valid value", + {"password": "a"}, + _tool_stub(_classify_args), + ) + == "Ask the user for valid value" + ) + + +def test_long_secret_is_masked_inside_a_token() -> None: + assert ( + redact_agent_text( + "Request failed with pw=hunter2xyz;", + {"password": "hunter2xyz"}, + _tool_stub(_classify_args), + ) + == "Request failed with pw=;" + ) diff --git a/tests/unit_tests/test_mcp_trusted_execution.py b/tests/unit_tests/test_mcp_trusted_execution.py index e3b3b355b..8e3551381 100644 --- a/tests/unit_tests/test_mcp_trusted_execution.py +++ b/tests/unit_tests/test_mcp_trusted_execution.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import logging import os from pathlib import Path from typing import TYPE_CHECKING, Callable @@ -282,6 +283,26 @@ def test_local_helpers_reject_when_untrusted( call_helper() +def test_run_sql_query_log_omits_exception_text( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + class FailingCache: + def run_sql_query(self, sql_query: str, max_records: int) -> None: + raise RuntimeError("SENTINEL_SQL_ERROR") + + monkeypatch.setattr(local, "raise_if_untrusted_execution_context", lambda _: None) + monkeypatch.setattr(local, "get_default_cache", FailingCache) + + with caplog.at_level(logging.WARNING, logger=local.logger.name): + local.run_sql_query("SELECT 1", 10) + + assert "SENTINEL_SQL_ERROR" not in caplog.text + assert "RuntimeError" in caplog.text + assert len(caplog.records) == 1 + assert caplog.records[0].exc_info is None + + @pytest.mark.parametrize("enabled", [False, True], ids=["disabled", "enabled"]) def test_assert_http_trusted_execution_disabled( monkeypatch: MonkeyPatch, From 17383785113077d346d5402a76f282f05a55a9d2 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 06:24:01 +0000 Subject: [PATCH 21/22] chore(deps): pin fastmcp-extensions>=0.34.2 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8572e3aea..1573fa58f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "typing-extensions", "uuid7>=0.1.0,<1.0", "fastmcp>=4.0.9,<5.0", - "fastmcp-extensions>=0.34.1,<1.0.0", + "fastmcp-extensions>=0.34.2,<1.0.0", "starlette", # Transitive dependency of fastmcp, imported directly "httpx", # Transitive dependency of fastmcp, imported directly "mcp", # Transitive dependency of fastmcp, imported directly diff --git a/uv.lock b/uv.lock index c2eda87ac..6a8507054 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ requires-dist = [ { name = "duckdb", specifier = "==1.4.3" }, { name = "duckdb-engine", specifier = "==0.17.0" }, { name = "fastmcp", specifier = ">=4.0.9,<5.0" }, - { name = "fastmcp-extensions", specifier = ">=0.34.1,<1.0.0" }, + { name = "fastmcp-extensions", specifier = ">=0.34.2,<1.0.0" }, { name = "google-auth", specifier = ">=2.27.0,<3.0" }, { name = "google-cloud-bigquery", specifier = ">=3.12.0,<4.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.25.0,<3.0" }, @@ -1352,7 +1352,7 @@ wheels = [ [[package]] name = "fastmcp-extensions" -version = "0.34.1" +version = "0.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1367,9 +1367,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/40/5ad985b5cf5238d84eccc4a101641cce169fb73d20f591f43cbed895c045/fastmcp_extensions-0.34.1.tar.gz", hash = "sha256:734be63c580a5da6d69e2e804db9b960df07d6654f2b268ee75c79aa00e4c70b", size = 362674, upload-time = "2026-10-10T05:55:28.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/6d/379330f660ba2a4ed9fa7a6b0fe8c467f905469faa6aa26b6c55ca0a603a/fastmcp_extensions-0.34.2.tar.gz", hash = "sha256:b724514f60b65c3e73f0b4b6aaf2fc5b555bf30cd18f27bb6e8994497725e133", size = 356897, upload-time = "2026-10-10T06:22:20.759Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/3e/bf4a1edcd67a33bb40bbb100bef079a775467db92b096c2d177b2c085648/fastmcp_extensions-0.34.1-py3-none-any.whl", hash = "sha256:24c11884875ecbbb26bb03547ad09c83cc11c210e6a49709dfa52cbcef1f6b94", size = 162200, upload-time = "2026-10-10T05:55:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/92/77/5cc2e6de8ab239969811cdb1be44a59359c879e7494f8dcf71b037d04e4e/fastmcp_extensions-0.34.2-py3-none-any.whl", hash = "sha256:8628542fb17ea016519ae755c6aaa429b62ee042929009fc7d961cef3a0a7cbd", size = 156076, upload-time = "2026-10-10T06:22:19.108Z" }, ] [[package]] From 1a7ea031ed6e3fee7c6991720181ae496dc3e564 Mon Sep 17 00:00:00 2001 From: "caleb.evans" Date: Sat, 10 Oct 2026 06:42:17 +0000 Subject: [PATCH 22/22] fix(mcp): harden error telemetry and validation paths Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- airbyte/_connector_base.py | 15 +++++++-- airbyte/mcp/_error_handling.py | 2 +- airbyte/mcp/_otel.py | 5 +++ tests/unit_tests/test_connector_base.py | 29 ++++++++++++++++ tests/unit_tests/test_mcp_otel.py | 33 +++++++++++++++++-- tests/unit_tests/test_mcp_tool_errors.py | 22 ++++++++++++- .../unit_tests/test_mcp_trusted_execution.py | 7 +++- 7 files changed, 105 insertions(+), 8 deletions(-) diff --git a/airbyte/_connector_base.py b/airbyte/_connector_base.py index d14eb0591..f7917c1e7 100644 --- a/airbyte/_connector_base.py +++ b/airbyte/_connector_base.py @@ -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 @@ -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.""" @@ -186,7 +196,8 @@ def validate_config(self, config: dict[str, Any] | None = None) -> None: validation_ex = exc.AirbyteConnectorValidationFailedError( connector_name=self.name, message=( - f"The provided config is not valid at `{ex.json_path}` " + f"The provided config is not valid at " + f"`{_json_path_from_absolute_path(ex.absolute_path)}` " f"(failed the `{ex.validator}` rule)." ), context={ diff --git a/airbyte/mcp/_error_handling.py b/airbyte/mcp/_error_handling.py index 9bea94632..ca80f9d83 100644 --- a/airbyte/mcp/_error_handling.py +++ b/airbyte/mcp/_error_handling.py @@ -255,7 +255,7 @@ def redact_agent_text( if leaf in seen: continue seen.add(leaf) - placeholder = f"" + placeholder = f"" if secret and len(leaf) >= 4: # noqa: PLR2004 text = text.replace(leaf, placeholder) else: diff --git a/airbyte/mcp/_otel.py b/airbyte/mcp/_otel.py index e9cdc6b90..051402741 100644 --- a/airbyte/mcp/_otel.py +++ b/airbyte/mcp/_otel.py @@ -196,6 +196,11 @@ def _rebuild(span: ReadableSpan) -> ReadableSpan: "organization_id", "scope_source", "error_type", + "error.category", + "error.fault", + "error.reason", + "error.cause_types", + "upstream.status_code", "outcome", "auth_method", "mcp_protocol_version", diff --git a/tests/unit_tests/test_connector_base.py b/tests/unit_tests/test_connector_base.py index 42f7a7fac..b12533485 100644 --- a/tests/unit_tests/test_connector_base.py +++ b/tests/unit_tests/test_connector_base.py @@ -3,11 +3,14 @@ from __future__ import annotations +from collections import deque from types import SimpleNamespace from unittest.mock import Mock +import jsonschema import pytest +from airbyte._connector_base import _json_path_from_absolute_path from airbyte.exceptions import AirbyteConnectorValidationFailedError from airbyte.sources.base import Source @@ -43,3 +46,29 @@ def test_validate_config_never_repeats_a_hydrated_secret() -> None: assert SECRET_VALUE not in str(error) assert SECRET_VALUE not in error.get_message() assert SECRET_VALUE not in str(error.context) + + +def test_json_path_from_absolute_path_matches_jsonschema() -> None: + schema = { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"name": {"type": "string"}}, + }, + }, + }, + } + error = next( + jsonschema.Draft202012Validator(schema).iter_errors({"items": [{"name": 1}]}) + ) + + assert _json_path_from_absolute_path(error.absolute_path) == error.json_path + + +def test_json_path_from_absolute_path_uses_only_path_components() -> None: + error = jsonschema.ValidationError("invalid", path=deque(["a", 0, "b"])) + + assert _json_path_from_absolute_path(error.absolute_path) == "$.a[0].b" diff --git a/tests/unit_tests/test_mcp_otel.py b/tests/unit_tests/test_mcp_otel.py index 6d5332c1f..fde65c00c 100644 --- a/tests/unit_tests/test_mcp_otel.py +++ b/tests/unit_tests/test_mcp_otel.py @@ -360,11 +360,32 @@ def echo(value: str) -> str: }, ) + span = _tool_span(spans, "echo") + span = ReadableSpan( + name=span.name, + context=span.context, + parent=span.parent, + resource=span.resource, + attributes={ + **(span.attributes or {}), + "airbyte.mcp.error.category": "upstream", + "airbyte.mcp.error.fault": True, + "airbyte.mcp.error.reason": "rate_limited", + "airbyte.mcp.error.cause_types": ("SDKError", "HTTPError"), + "airbyte.mcp.upstream.status_code": 503, + "airbyte.mcp.error.stack": "RuntimeError", + }, + events=span.events, + links=span.links, + kind=span.kind, + status=span.status, + start_time=span.start_time, + end_time=span.end_time, + instrumentation_scope=span.instrumentation_scope, + ) collector = _Collector() assert ( - observability._DatadogMetadataExporter(collector).export([ - _tool_span(spans, "echo") - ]) + observability._DatadogMetadataExporter(collector).export([span]) == SpanExportResult.SUCCESS ) attributes = collector.spans[0].attributes or {} @@ -375,6 +396,12 @@ def echo(value: str) -> str: assert metadata["workspace_id"] == WORKSPACE_ID assert metadata["organization_id"] == ORGANIZATION_ID assert metadata["auth_method"] == "bearer" + assert metadata["error.category"] == "upstream" + assert metadata["error.fault"] is True + assert metadata["error.reason"] == "rate_limited" + assert metadata["error.cause_types"] == ["SDKError", "HTTPError"] + assert metadata["upstream.status_code"] == 503 + assert "error.stack" not in metadata assert metadata["arg.value"] == attributes["airbyte.mcp.arg.value"] assert metadata["arg_hash_status"] == attributes["airbyte.mcp.arg_hash_status"] assert json.loads(attributes["gen_ai.tool.call.arguments"]) == { diff --git a/tests/unit_tests/test_mcp_tool_errors.py b/tests/unit_tests/test_mcp_tool_errors.py index ea70b57ef..0c9cf5484 100644 --- a/tests/unit_tests/test_mcp_tool_errors.py +++ b/tests/unit_tests/test_mcp_tool_errors.py @@ -402,10 +402,30 @@ def read_records(config: dict | str | None, stream_name: str) -> str: assert secret not in text assert "sk_live" not in text - assert "" in text + assert "" in text assert "stream_with_secret_value" not in text +def test_redaction_placeholder_does_not_expose_nested_mapping_key() -> None: + server = _server_with_agent_error_text() + hostile_key = "password-or-injected-text" + secret = "sk_live_FAKE1234567890" + + @server.tool + def read_records(config: dict[str, str]) -> str: + raise AirbyteLibInputError(message=f"Failed to read key {config[hostile_key]}") + + text = _call_error( + server, + "read_records", + {"config": {hostile_key: secret}}, + ) + + assert secret not in text + assert hostile_key not in text + assert "" in text + + def _tool_stub(fn): return SimpleNamespace(fn=fn, name="test-tool") diff --git a/tests/unit_tests/test_mcp_trusted_execution.py b/tests/unit_tests/test_mcp_trusted_execution.py index 8e3551381..75ea06a5d 100644 --- a/tests/unit_tests/test_mcp_trusted_execution.py +++ b/tests/unit_tests/test_mcp_trusted_execution.py @@ -294,8 +294,13 @@ def run_sql_query(self, sql_query: str, max_records: int) -> None: monkeypatch.setattr(local, "raise_if_untrusted_execution_context", lambda _: None) monkeypatch.setattr(local, "get_default_cache", FailingCache) - with caplog.at_level(logging.WARNING, logger=local.logger.name): + monkeypatch.setattr(local.logger, "propagate", False) + local.logger.addHandler(caplog.handler) + try: + caplog.set_level(logging.WARNING, logger=local.logger.name) local.run_sql_query("SELECT 1", 10) + finally: + local.logger.removeHandler(caplog.handler) assert "SENTINEL_SQL_ERROR" not in caplog.text assert "RuntimeError" in caplog.text