Skip to content

Commit d991b1e

Browse files
authored
Merge pull request #195 from adcontextprotocol/bokelley/issue-193-mcp-text-extraction
feat: MCP response + error extraction per AdCP spec
2 parents ba53961 + fbff8ce commit d991b1e

7 files changed

Lines changed: 1405 additions & 26 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,6 @@ IMPLEMENTATION_PLAN.md
160160
IMPLEMENTATION_SUMMARY.md
161161
TESTING_STATUS.md
162162
IMPLEMENTATION_PLAN.md
163+
164+
# Claude Code harness scratch — scheduled-task lock, session state, etc.
165+
.claude/

src/adcp/protocols/mcp.py

Lines changed: 143 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
HTTPX_AVAILABLE = False
4242
HTTPStatusError = None # type: ignore[assignment, misc]
4343

44+
import json
45+
4446
from adcp import _idempotency
4547
from adcp.exceptions import (
4648
ADCPConnectionError,
@@ -51,6 +53,114 @@
5153
from adcp.protocols.base import ProtocolAdapter
5254
from 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

55165
class 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

Comments
 (0)