4141 HTTPX_AVAILABLE = False
4242 HTTPStatusError = None # type: ignore[assignment, misc]
4343
44+ import json
45+
4446from adcp import _idempotency
4547from adcp .exceptions import (
4648 ADCPConnectionError ,
5153from adcp .protocols .base import ProtocolAdapter
5254from adcp .types .core import DebugInfo , TaskResult , TaskStatus
5355
56+ # Spec-defined limits from docs/building/implementation/mcp-response-extraction.mdx
57+ # and docs/building/implementation/transport-errors.mdx.
58+ _MAX_TEXT_SIZE_BYTES = 1_048_576 # 1MB cap on text items before JSON.parse
59+ _MAX_ERROR_SIZE_BYTES = 4096 # total adcp_error JSON-serialized size
60+ _MAX_ERROR_CODE_LEN = 64
61+
62+
63+ def _text_of (item : Any ) -> str | None :
64+ """Return the text payload of an MCP content item, or None if not a text item."""
65+ if isinstance (item , dict ):
66+ if item .get ("type" ) != "text" :
67+ return None
68+ text = item .get ("text" )
69+ else :
70+ if getattr (item , "type" , None ) != "text" :
71+ return None
72+ text = getattr (item , "text" , None )
73+ return text if isinstance (text , str ) and text else None
74+
75+
76+ def extract_adcp_success (result : Any ) -> dict [str , Any ] | None :
77+ """Extract AdCP success response data from an MCP tool result.
78+
79+ Implements the normative algorithm from AdCP spec §MCP Response Extraction
80+ (docs/building/implementation/mcp-response-extraction.mdx):
81+
82+ 1. If ``isError`` is truthy, return ``None`` — error extraction is a
83+ separate path.
84+ 2. ``structuredContent`` — if present and a non-array object that is NOT
85+ an ``adcp_error``-only payload, return it.
86+ 3. Text fallback — iterate ``content[]`` in order; for each ``type='text'``
87+ item within the 1MB size limit, ``json.loads`` and return the result
88+ if it is a non-array object that is NOT ``adcp_error``-only.
89+ 4. No structured data found — return ``None``.
90+ """
91+ if getattr (result , "isError" , False ):
92+ return None
93+
94+ sc = getattr (result , "structuredContent" , None )
95+ if isinstance (sc , dict ) and not (len (sc ) == 1 and "adcp_error" in sc ):
96+ return sc
97+
98+ for item in getattr (result , "content" , None ) or []:
99+ text = _text_of (item )
100+ if text is None or len (text ) > _MAX_TEXT_SIZE_BYTES :
101+ continue
102+ try :
103+ parsed = json .loads (text )
104+ except (json .JSONDecodeError , ValueError ):
105+ continue
106+ if (
107+ isinstance (parsed , dict )
108+ and not (len (parsed ) == 1 and "adcp_error" in parsed )
109+ ):
110+ return parsed
111+ return None
112+
113+
114+ def _validate_adcp_error (err : Any ) -> dict [str , Any ] | None :
115+ """Per transport-errors.mdx: ``code`` must be a non-empty string ≤ 64 chars,
116+ total serialized size ≤ 4KB. Returns the validated error or None."""
117+ if not isinstance (err , dict ):
118+ return None
119+ code = err .get ("code" )
120+ if not isinstance (code , str ) or not (0 < len (code ) <= _MAX_ERROR_CODE_LEN ):
121+ return None
122+ try :
123+ if len (json .dumps (err )) > _MAX_ERROR_SIZE_BYTES :
124+ return None
125+ except (TypeError , ValueError ):
126+ return None
127+ return err
128+
129+
130+ def extract_adcp_error (result : Any ) -> dict [str , Any ] | None :
131+ """Extract and validate an AdCP ``adcp_error`` object from an MCP result.
132+
133+ Implements AdCP spec §Client Detection Order (MCP paths 1 + 5) from
134+ docs/building/implementation/transport-errors.mdx. Only applies when
135+ ``isError`` is truthy. Returns a validated error object or ``None``.
136+ """
137+ if not getattr (result , "isError" , False ):
138+ return None
139+
140+ sc = getattr (result , "structuredContent" , None )
141+ if isinstance (sc , dict ):
142+ validated = _validate_adcp_error (sc .get ("adcp_error" ))
143+ if validated is not None :
144+ return validated
145+
146+ for item in getattr (result , "content" , None ) or []:
147+ text = _text_of (item )
148+ # Apply the same 1MB pre-parse cap as the success path to prevent a
149+ # malicious server returning ``isError=true`` plus a giant payload from
150+ # forcing a multi-MB json.loads into memory before the 4KB validation
151+ # would reject it.
152+ if text is None or len (text ) > _MAX_TEXT_SIZE_BYTES :
153+ continue
154+ try :
155+ parsed = json .loads (text )
156+ except (json .JSONDecodeError , ValueError ):
157+ continue
158+ if isinstance (parsed , dict ):
159+ validated = _validate_adcp_error (parsed .get ("adcp_error" ))
160+ if validated is not None :
161+ return validated
162+ return None
163+
54164
55165class MCPAdapter (ProtocolAdapter ):
56166 """Adapter for MCP protocol using official Python MCP SDK."""
@@ -350,28 +460,41 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
350460 message_text = item ["text" ]
351461 break
352462
353- # Handle error responses
463+ # Handle error responses per transport-errors.mdx §Client Detection
464+ # Order. Extract the adcp_error object from structuredContent first,
465+ # then from text fallback — whichever is present.
354466 if is_error :
355- # For error responses, structuredContent is optional
356- # Use the error message from content as the error
357- error_message = message_text or "Tool execution failed"
358- structured_error = getattr (result , "structuredContent" , None )
359- # Prefer structured error codes when present, then fall back to
360- # scanning the text content — many MCP servers (FastMCP default)
361- # return is_error=true with only a text body carrying the code.
362- _idempotency .raise_for_idempotency_error (
363- tool_name , structured_error , self .agent_config .id
364- )
467+ adcp_error = extract_adcp_error (result )
468+ # Raise typed idempotency exceptions before building a generic
469+ # TaskResult(failed), so callers that catch them distinctly
470+ # don't lose the signal.
471+ if adcp_error and adcp_error .get ("code" ) in (
472+ "IDEMPOTENCY_CONFLICT" ,
473+ "IDEMPOTENCY_EXPIRED" ,
474+ ):
475+ from adcp .exceptions import classify_task_error
476+
477+ raise classify_task_error (
478+ tool_name , [adcp_error ], agent_id = self .agent_config .id
479+ )
480+ # FastMCP-style is_error with plain-text content: text-match
481+ # fallback for the two idempotency codes.
365482 _idempotency .raise_for_idempotency_text (
366483 tool_name , message_text , self .agent_config .id
367484 )
485+ error_message = (
486+ (adcp_error .get ("message" ) if adcp_error else None )
487+ or message_text
488+ or "Tool execution failed"
489+ )
368490 if self .agent_config .debug and start_time :
369491 duration_ms = (time .time () - start_time ) * 1000
370492 debug_info = DebugInfo (
371493 request = debug_request ,
372494 response = {
373495 "error" : error_message ,
374496 "is_error" : True ,
497+ "adcp_error" : adcp_error ,
375498 },
376499 duration_ms = duration_ms ,
377500 )
@@ -383,18 +506,19 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
383506 idempotency_key = idempotency_key ,
384507 )
385508
386- # For successful responses, structuredContent is required
387- if not hasattr (result , "structuredContent" ) or result .structuredContent is None :
509+ # Success extraction per mcp-response-extraction.mdx §Extraction
510+ # Algorithm: prefer structuredContent (MCP 2025-03-26+), fall back
511+ # to JSON-parsing content[].text for older servers (including the
512+ # AdCP reference training agent).
513+ data_to_return = extract_adcp_success (result )
514+ if data_to_return is None :
388515 raise ValueError (
389- f"MCP tool { tool_name } did not return structuredContent . "
390- f"This SDK requires MCP tools to provide structured responses "
391- f"for successful calls . "
516+ f"MCP tool { tool_name } returned no structured AdCP data . "
517+ f"Neither structuredContent nor content[].text yielded a "
518+ f"parseable non-adcp_error JSON object . "
392519 f"Got content: { result .content if hasattr (result , 'content' ) else 'none' } "
393520 )
394521
395- # Extract the structured data (required for success)
396- data_to_return = result .structuredContent
397-
398522 if self .agent_config .debug and start_time :
399523 duration_ms = (time .time () - start_time ) * 1000
400524 debug_info = DebugInfo (
0 commit comments