Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions src/adcp/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,16 +1684,15 @@ def _register_tool(
"""
from mcp.server.fastmcp.tools import Tool
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
from mcp.types import CallToolResult
from pydantic import ConfigDict

from adcp.exceptions import ADCPError
from adcp.server.translate import translate_error
from adcp.server.translate import build_mcp_error_result

# Lazy import — decisioning is optional for non-platform handlers,
# but when present its ``AdcpError`` carries structured ``details``
# (caused_by, validation_errors) that ``translate_error`` now
# understands. AudioStack Emma P0: pre-fix this exception class
# propagated to FastMCP's default handler and ``details`` was lost.
# (caused_by, validation_errors) that need to reach the wire.
try:
from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813
except Exception:
Expand Down Expand Up @@ -1738,26 +1737,30 @@ async def _call_handler() -> Any:
else:
result = await _call_handler()
except ADCPError as exc:
# Translate AdCP-typed exceptions (IdempotencyConflictError,
# ADCPTaskError with a spec code, etc.) into a ToolError so FastMCP
# surfaces ``is_error=true`` with the spec error code in the
# message text. Clients per AdCP §transport-errors will extract
# the code via either structuredContent.adcp_error (if populated)
# or the text-fallback path.
raise translate_error(exc, protocol="mcp") from exc
# AdCP-typed exceptions (IdempotencyConflictError, ADCPTaskError
# with a spec code, etc.) project to a CallToolResult with
# ``isError=True`` AND ``structuredContent.adcp_error`` populated
# — matching transport-errors.mdx §MCP Binding. Returning the
# result directly bypasses FastMCP's ``_make_error_result`` path
# which strips ``structuredContent`` from error envelopes. The
# ``-> dict[str, Any]`` annotation drives FastMCP's output_schema
# derivation; the actual return type is broader (CallToolResult
# is a valid return per the lowlevel handler's contract).
return build_mcp_error_result(exc) # type: ignore[return-value]
except Exception as exc:
# Decisioning ``AdcpError`` is NOT a subclass of
# ``adcp.exceptions.ADCPError`` (different class hierarchy
# — ``adcp.decisioning.types.AdcpError``). Without this
# branch it propagated to FastMCP's default exception
# handler and ``details`` was lost on the wire. AudioStack
# Emma P0 confirmed pre-fix.
# — ``adcp.decisioning.types.AdcpError``). Catch it explicitly
# and project the same structured envelope.
if DecisioningAdcpError is not None and isinstance(exc, DecisioningAdcpError):
# ``# type: ignore[arg-type]`` because mypy can't see
# that ``translate_error`` accepts decisioning AdcpError
# via the lazy-import branch.
raise translate_error(exc, protocol="mcp") from exc # type: ignore[arg-type]
return build_mcp_error_result(exc) # type: ignore[return-value]
raise
# Pre-built CallToolResult (error envelope from build_mcp_error_result)
# passes through FastMCP's convert_result and the lowlevel handler
# without re-validation against the success-path output_model — the
# custom FuncMetadata subclass below handles the bypass.
if isinstance(result, CallToolResult):
return result # type: ignore[return-value]
if hasattr(result, "model_dump"):
return result.model_dump(mode="json", exclude_none=True) # type: ignore[no-any-return]
if isinstance(result, dict):
Expand All @@ -1784,6 +1787,23 @@ def model_dump_one_level(self) -> dict[str, Any]:
result.update(self.model_extra)
return result

class _AdcpFuncMetadata(FuncMetadata):
"""FuncMetadata that skips success-path output validation for error
``CallToolResult`` returns.

FastMCP's stock ``convert_result`` validates ``result.structuredContent``
against the success-path ``output_model`` whenever the tool returns a
``CallToolResult`` — but when the framework projects an ``AdcpError``
as ``{"adcp_error": {...}}``, that payload doesn't conform to the
success schema. Skip validation for ``isError=True`` envelopes; success
envelopes still validate normally.
"""

def convert_result(self, result: Any) -> Any:
if isinstance(result, CallToolResult) and result.isError:
return result
return super().convert_result(result)

# Advertise the spec response schema on ``tools/list`` when one is
# available. FastMCP serializes ``Tool.output_schema`` (which reads
# ``fn_metadata.output_schema``) into the ``outputSchema`` field of
Expand All @@ -1793,7 +1813,7 @@ def model_dump_one_level(self) -> dict[str, Any]:
effective_output_schema = (
output_schema if output_schema is not None else tool.fn_metadata.output_schema
)
tool.fn_metadata = FuncMetadata(
tool.fn_metadata = _AdcpFuncMetadata(
arg_model=_AdcpArgs,
output_schema=effective_output_schema,
output_model=tool.fn_metadata.output_model,
Expand Down
190 changes: 125 additions & 65 deletions src/adcp/server/translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from a2a.utils.errors import A2AError, InternalError, InvalidParamsError
from mcp.server.fastmcp.exceptions import ToolError
from mcp.types import CallToolResult, TextContent

from adcp.exceptions import (
ADCPAuthenticationError,
Expand Down Expand Up @@ -100,6 +101,120 @@ def _build_error_data(
return data


def _extract_structured_fields(
exc: ADCPError | Error | Any,
) -> tuple[str, str, str, str | None, str | None, dict[str, Any] | None, list[Any] | None]:
"""Extract (code, message, recovery, field, suggestion, details, errors).

Handles three input shapes:
- ``adcp.types.Error`` (Pydantic model)
- ``adcp.decisioning.types.AdcpError`` (decisioning-layer exception)
- ``adcp.exceptions.ADCPError`` (client-side exception, including ADCPTaskError)

Used by both ``translate_error`` and ``build_mcp_error_result`` so the
field-extraction logic stays in one place.
"""
# Lazy import — ``adcp.decisioning.types`` pulls in the decisioning
# graph, which translate.py shouldn't load at module-import time.
try:
from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813
except Exception:
DecisioningAdcpError = None # type: ignore[assignment,misc] # noqa: N806

field: str | None = None
if isinstance(exc, Error):
code = exc.code
message = exc.message
suggestion = exc.suggestion
details = exc.details
# Error.recovery is an Optional Recovery enum; unwrap to a string
# for downstream wire projection. Falls back to the recovery
# classification looked up from the code when unset.
recovery_val = exc.recovery
if recovery_val is None:
recovery = _recovery_for_code(code)
elif hasattr(recovery_val, "value"):
recovery = recovery_val.value
else:
recovery = str(recovery_val)
errors = None
field = exc.field
elif DecisioningAdcpError is not None and isinstance(exc, DecisioningAdcpError):
code = exc.code
message = exc.args[0] if exc.args else ""
suggestion = exc.suggestion
recovery = exc.recovery
details = exc.details or None
errors = None
field = exc.field
elif isinstance(exc, ADCPError):
code = _error_code_for_exception(exc)
message = exc.message
suggestion = exc.suggestion
recovery = _recovery_for_code(code)
details = None
errors = getattr(exc, "errors", None)
if errors:
first = errors[0]
field = getattr(first, "field", None)
details = getattr(first, "details", None)
else:
raise TypeError(f"Expected ADCPError or Error, got {type(exc).__name__}")

return code, message, recovery, field, suggestion, details, errors


def build_mcp_error_result(exc: ADCPError | Error | Any) -> CallToolResult:
"""Build an MCP ``CallToolResult`` carrying the structured ``adcp_error`` envelope.

The framework dispatcher returns this when a platform method raises a
structured AdCP error. The result has ``isError=True`` AND
``structuredContent={"adcp_error": {...}}`` on the same envelope —
matching the spec's transport-errors.mdx §MCP Binding shape that the
storyboard runner's ``/adcp_error/code`` JSON-pointer assertion
expects.

The text fallback in ``content[]`` preserves human-readable display
for clients that do not consume ``structuredContent`` (LLM tool-use
surfaces, log viewers).

Buyer agents read the structured envelope first; the text fallback
is only consulted when ``structuredContent`` is absent, per the
spec's structured-error precedence rules.
"""
code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(exc)

adcp_error: dict[str, Any] = {
"code": code,
"message": message,
"recovery": recovery,
}
if field is not None:
adcp_error["field"] = field
if suggestion is not None:
adcp_error["suggestion"] = suggestion
# ``retry_after`` lives on decisioning AdcpError; project it when present.
retry_after = getattr(exc, "retry_after", None)
if retry_after is not None:
adcp_error["retry_after"] = retry_after
if details:
adcp_error["details"] = dict(details)

# Text fallback for clients that don't read structuredContent.
if field:
text = f"{code}[{field}]: {message}"
else:
text = f"{code}: {message}"
if suggestion:
text += f"\nSuggestion: {suggestion}"

return CallToolResult(
content=[TextContent(type="text", text=text)],
structuredContent={"adcp_error": adcp_error},
isError=True,
)


def translate_error(
exc: ADCPError | Error,
protocol: Literal["mcp", "a2a"] | Protocol,
Expand Down Expand Up @@ -144,62 +259,7 @@ def translate_error(
if proto not in ("mcp", "a2a"):
raise ValueError(f"protocol must be 'mcp' or 'a2a', got {protocol!r}")

# Lazy import — ``adcp.decisioning.types`` pulls in the decisioning
# graph, which translate.py shouldn't load at module-import time.
# Match against ``BaseException`` here and let the elif body do the
# actual isinstance check via the imported name.
try:
from adcp.decisioning.types import AdcpError as DecisioningAdcpError # noqa: N813
except Exception:
DecisioningAdcpError = None # type: ignore[assignment,misc] # noqa: N806

# Extract structured fields from the input
field: str | None = None
if isinstance(exc, Error):
code = exc.code
message = exc.message
suggestion = exc.suggestion
details = exc.details
recovery = _recovery_for_code(code)
errors = None
field = exc.field
elif DecisioningAdcpError is not None and isinstance(exc, DecisioningAdcpError):
# Decisioning-layer ``AdcpError`` (distinct from
# ``adcp.exceptions.ADCPError``) carries its own structured
# ``details`` (e.g. ``caused_by`` from the INTERNAL_ERROR wrap,
# ``validation_errors`` from #341's narrowing). AudioStack Emma
# P0: pre-fix this branch was missing entirely, so AdcpError
# propagated to FastMCP's default exception path and ``details``
# never reached the wire.
code = exc.code
message = exc.args[0] if exc.args else ""
suggestion = exc.suggestion
recovery = exc.recovery
details = exc.details or None
errors = None
field = exc.field
elif isinstance(exc, ADCPError):
code = _error_code_for_exception(exc)
message = exc.message
suggestion = exc.suggestion
recovery = _recovery_for_code(code)
details = None
errors = getattr(exc, "errors", None)
# ADCPTaskError carries a list of Error objects — lift the first
# error's ``field`` so MCP clients see the field path too (A2A
# already surfaces it inside ``data.errors[i].field`` via the
# structured error passthrough).
if errors:
first = errors[0]
field = getattr(first, "field", None)
# ADCPTaskError errors[0].details (e.g. validation_errors
# from create_tool_caller's INVALID_REQUEST projection)
# should also reach MCP — A2A already passes them via the
# ``errors`` array, but MCP only sees the first error's
# field/message. Embed details too.
details = getattr(first, "details", None)
else:
raise TypeError(f"Expected ADCPError or Error, got {type(exc).__name__}")
code, message, recovery, field, suggestion, details, errors = _extract_structured_fields(exc)

if proto == "mcp":
return _to_mcp(code, message, suggestion=suggestion, field=field, details=details)
Expand Down Expand Up @@ -242,15 +302,15 @@ def _to_mcp(
reached MCP buyers — only A2A. Now both transports surface the
structured breadcrumb.

**Bridge, not endpoint.** The protocol-correct shape is MCP's
``CallToolResult.structuredContent`` (carries ``isError=True``
AND a structured ``adcp_error`` object on the same envelope —
see ``mcp.types.CallToolResult``). FastMCP's
``_make_error_result`` (``mcp/server/lowlevel/server.py:467``)
drops ``structuredContent`` for error results, so we can't reach
that channel via FastMCP's ``ToolError`` raise path. Migrating
to lowlevel ``Server.call_tool`` registration would unlock it;
until then this text-suffix is the working bridge.
**For proxy / custom-transport callers only.** The standard
framework path (``serve()`` / ``ADCPAgentExecutor``) projects the
structured envelope via :func:`build_mcp_error_result` directly,
bypassing FastMCP's ``_make_error_result`` (which drops
``structuredContent`` for error results). This text-payload
``ToolError`` shape exists for adopters running custom MCP servers
that catch ``ADCPError`` and need a single value to ``raise`` —
the field-bracket prefix gives clients a programmatic handle even
on the text-only channel.
"""
if field:
text = f"{code}[{field}]: {message}"
Expand Down
Loading
Loading