@@ -42,6 +42,115 @@ <h1 class="title">Module <code>adcp.protocols.mcp</code></h1>
4242< section >
4343</ section >
4444< section >
45+ < h2 class ="section-title " id ="header-functions "> Functions</ h2 >
46+ < dl >
47+ < dt id ="adcp.protocols.mcp.extract_adcp_error "> < code class ="name flex ">
48+ < span > def < span class ="ident "> extract_adcp_error</ span > </ span > (< span > result: Any) ‑> dict[str, typing.Any] | None</ span >
49+ </ code > </ dt >
50+ < dd >
51+ < details class ="source ">
52+ < summary >
53+ < span > Expand source code</ span >
54+ </ summary >
55+ < pre > < code class ="python "> def extract_adcp_error(result: Any) -> dict[str, Any] | None:
56+ """Extract and validate an AdCP ``adcp_error`` object from an MCP result.
57+
58+ Implements AdCP spec §Client Detection Order (MCP paths 1 + 5) from
59+ docs/building/implementation/transport-errors.mdx. Only applies when
60+ ``isError`` is truthy. Returns a validated error object or ``None``.
61+ """
62+ if not getattr(result, "isError", False):
63+ return None
64+
65+ sc = getattr(result, "structuredContent", None)
66+ if isinstance(sc, dict):
67+ validated = _validate_adcp_error(sc.get("adcp_error"))
68+ if validated is not None:
69+ return validated
70+
71+ for item in getattr(result, "content", None) or []:
72+ text = _text_of(item)
73+ # Apply the same 1MB pre-parse cap as the success path to prevent a
74+ # malicious server returning ``isError=true`` plus a giant payload from
75+ # forcing a multi-MB json.loads into memory before the 4KB validation
76+ # would reject it.
77+ if text is None or len(text) > _MAX_TEXT_SIZE_BYTES:
78+ continue
79+ try:
80+ parsed = json.loads(text)
81+ except (json.JSONDecodeError, ValueError):
82+ continue
83+ if isinstance(parsed, dict):
84+ validated = _validate_adcp_error(parsed.get("adcp_error"))
85+ if validated is not None:
86+ return validated
87+ return None</ code > </ pre >
88+ </ details >
89+ < div class ="desc "> < p > Extract and validate an AdCP < code > adcp_error</ code > object from an MCP result.</ p >
90+ < p > Implements AdCP spec §Client Detection Order (MCP paths 1 + 5) from
91+ docs/building/implementation/transport-errors.mdx. Only applies when
92+ < code > isError</ code > is truthy. Returns a validated error object or < code > None</ code > .</ p > </ div >
93+ </ dd >
94+ < dt id ="adcp.protocols.mcp.extract_adcp_success "> < code class ="name flex ">
95+ < span > def < span class ="ident "> extract_adcp_success</ span > </ span > (< span > result: Any) ‑> dict[str, typing.Any] | None</ span >
96+ </ code > </ dt >
97+ < dd >
98+ < details class ="source ">
99+ < summary >
100+ < span > Expand source code</ span >
101+ </ summary >
102+ < pre > < code class ="python "> def extract_adcp_success(result: Any) -> dict[str, Any] | None:
103+ """Extract AdCP success response data from an MCP tool result.
104+
105+ Implements the normative algorithm from AdCP spec §MCP Response Extraction
106+ (docs/building/implementation/mcp-response-extraction.mdx):
107+
108+ 1. If ``isError`` is truthy, return ``None`` — error extraction is a
109+ separate path.
110+ 2. ``structuredContent`` — if present and a non-array object that is NOT
111+ an ``adcp_error``-only payload, return it.
112+ 3. Text fallback — iterate ``content[]`` in order; for each ``type='text'``
113+ item within the 1MB size limit, ``json.loads`` and return the result
114+ if it is a non-array object that is NOT ``adcp_error``-only.
115+ 4. No structured data found — return ``None``.
116+ """
117+ if getattr(result, "isError", False):
118+ return None
119+
120+ sc = getattr(result, "structuredContent", None)
121+ if isinstance(sc, dict) and not (len(sc) == 1 and "adcp_error" in sc):
122+ return sc
123+
124+ for item in getattr(result, "content", None) or []:
125+ text = _text_of(item)
126+ if text is None or len(text) > _MAX_TEXT_SIZE_BYTES:
127+ continue
128+ try:
129+ parsed = json.loads(text)
130+ except (json.JSONDecodeError, ValueError):
131+ continue
132+ if (
133+ isinstance(parsed, dict)
134+ and not (len(parsed) == 1 and "adcp_error" in parsed)
135+ ):
136+ return parsed
137+ return None</ code > </ pre >
138+ </ details >
139+ < div class ="desc "> < p > Extract AdCP success response data from an MCP tool result.</ p >
140+ < p > Implements the normative algorithm from AdCP spec §MCP Response Extraction
141+ (docs/building/implementation/mcp-response-extraction.mdx):</ p >
142+ < ol >
143+ < li > If < code > isError</ code > is truthy, return < code > None</ code > — error extraction is a
144+ separate path.</ li >
145+ < li > < code > structuredContent</ code > — if present and a non-array object that is NOT
146+ an < code > adcp_error</ code > -only payload, return it.</ li >
147+ < li > Text fallback — iterate < code > content[]</ code > in order; for each < code > type='text'</ code >
148+ item within the 1MB size limit, < code > json.loads</ code > and return the result
149+ if it is a non-array object that is NOT < code > adcp_error</ code > -only.</ li >
150+ < li > No structured data found — return < code > None</ code > .</ li >
151+ </ ol > </ div >
152+ </ dd >
153+ </ dl >
45154</ section >
46155< section >
47156< h2 class ="section-title " id ="header-classes "> Classes</ h2 >
@@ -353,28 +462,41 @@ <h2 class="section-title" id="header-classes">Classes</h2>
353462 message_text = item["text"]
354463 break
355464
356- # Handle error responses
465+ # Handle error responses per transport-errors.mdx §Client Detection
466+ # Order. Extract the adcp_error object from structuredContent first,
467+ # then from text fallback — whichever is present.
357468 if is_error:
358- # For error responses, structuredContent is optional
359- # Use the error message from content as the error
360- error_message = message_text or "Tool execution failed"
361- structured_error = getattr(result, "structuredContent", None)
362- # Prefer structured error codes when present, then fall back to
363- # scanning the text content — many MCP servers (FastMCP default)
364- # return is_error=true with only a text body carrying the code.
365- _idempotency.raise_for_idempotency_error(
366- tool_name, structured_error, self.agent_config.id
367- )
469+ adcp_error = extract_adcp_error(result)
470+ # Raise typed idempotency exceptions before building a generic
471+ # TaskResult(failed), so callers that catch them distinctly
472+ # don't lose the signal.
473+ if adcp_error and adcp_error.get("code") in (
474+ "IDEMPOTENCY_CONFLICT",
475+ "IDEMPOTENCY_EXPIRED",
476+ ):
477+ from adcp.exceptions import classify_task_error
478+
479+ raise classify_task_error(
480+ tool_name, [adcp_error], agent_id=self.agent_config.id
481+ )
482+ # FastMCP-style is_error with plain-text content: text-match
483+ # fallback for the two idempotency codes.
368484 _idempotency.raise_for_idempotency_text(
369485 tool_name, message_text, self.agent_config.id
370486 )
487+ error_message = (
488+ (adcp_error.get("message") if adcp_error else None)
489+ or message_text
490+ or "Tool execution failed"
491+ )
371492 if self.agent_config.debug and start_time:
372493 duration_ms = (time.time() - start_time) * 1000
373494 debug_info = DebugInfo(
374495 request=debug_request,
375496 response={
376497 "error": error_message,
377498 "is_error": True,
499+ "adcp_error": adcp_error,
378500 },
379501 duration_ms=duration_ms,
380502 )
@@ -386,18 +508,19 @@ <h2 class="section-title" id="header-classes">Classes</h2>
386508 idempotency_key=idempotency_key,
387509 )
388510
389- # For successful responses, structuredContent is required
390- if not hasattr(result, "structuredContent") or result.structuredContent is None:
511+ # Success extraction per mcp-response-extraction.mdx §Extraction
512+ # Algorithm: prefer structuredContent (MCP 2025-03-26+), fall back
513+ # to JSON-parsing content[].text for older servers (including the
514+ # AdCP reference training agent).
515+ data_to_return = extract_adcp_success(result)
516+ if data_to_return is None:
391517 raise ValueError(
392- f"MCP tool {tool_name} did not return structuredContent . "
393- f"This SDK requires MCP tools to provide structured responses "
394- f"for successful calls . "
518+ f"MCP tool {tool_name} returned no structured AdCP data . "
519+ f"Neither structuredContent nor content[].text yielded a "
520+ f"parseable non-adcp_error JSON object . "
395521 f"Got content: {result.content if hasattr(result, 'content') else 'none'}"
396522 )
397523
398- # Extract the structured data (required for success)
399- data_to_return = result.structuredContent
400-
401524 if self.agent_config.debug and start_time:
402525 duration_ms = (time.time() - start_time) * 1000
403526 debug_info = DebugInfo(
@@ -960,6 +1083,12 @@ <h3>Inherited members</h3>
9601083< li > < code > < a title ="adcp.protocols " href ="index.html "> adcp.protocols</ a > </ code > </ li >
9611084</ ul >
9621085</ li >
1086+ < li > < h3 > < a href ="#header-functions "> Functions</ a > </ h3 >
1087+ < ul class ="">
1088+ < li > < code > < a title ="adcp.protocols.mcp.extract_adcp_error " href ="#adcp.protocols.mcp.extract_adcp_error "> extract_adcp_error</ a > </ code > </ li >
1089+ < li > < code > < a title ="adcp.protocols.mcp.extract_adcp_success " href ="#adcp.protocols.mcp.extract_adcp_success "> extract_adcp_success</ a > </ code > </ li >
1090+ </ ul >
1091+ </ li >
9631092< li > < h3 > < a href ="#header-classes "> Classes</ a > </ h3 >
9641093< ul >
9651094< li >
0 commit comments