adcp 1.0.3__tar.gz → 1.0.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {adcp-1.0.3/src/adcp.egg-info → adcp-1.0.5}/PKG-INFO +1 -1
- {adcp-1.0.3 → adcp-1.0.5}/pyproject.toml +1 -1
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/__init__.py +1 -1
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/__main__.py +22 -21
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/base.py +3 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/mcp.py +65 -4
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/core.py +1 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/utils/response_parser.py +4 -1
- {adcp-1.0.3 → adcp-1.0.5/src/adcp.egg-info}/PKG-INFO +1 -1
- {adcp-1.0.3 → adcp-1.0.5}/tests/test_protocols.py +104 -3
- {adcp-1.0.3 → adcp-1.0.5}/LICENSE +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/README.md +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/setup.cfg +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/client.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/config.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/exceptions.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/__init__.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/a2a.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/__init__.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/generated.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/tasks.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/utils/__init__.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp/utils/operation_id.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/SOURCES.txt +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/dependency_links.txt +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/entry_points.txt +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/requires.txt +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/top_level.txt +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/tests/test_cli.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/tests/test_client.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/tests/test_code_generation.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/tests/test_format_id_validation.py +0 -0
- {adcp-1.0.3 → adcp-1.0.5}/tests/test_response_parser.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "adcp"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.5"
|
|
8
8
|
description = "Official Python client for the Ad Context Protocol (AdCP)"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "AdCP Community", email = "maintainers@adcontextprotocol.org"}
|
|
@@ -23,37 +23,38 @@ from adcp.types.core import AgentConfig, Protocol
|
|
|
23
23
|
|
|
24
24
|
def print_json(data: Any) -> None:
|
|
25
25
|
"""Print data as JSON."""
|
|
26
|
-
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
# Handle Pydantic models
|
|
29
|
+
if isinstance(data, BaseModel):
|
|
30
|
+
print(data.model_dump_json(indent=2, exclude_none=True))
|
|
31
|
+
else:
|
|
32
|
+
print(json.dumps(data, indent=2, default=str))
|
|
27
33
|
|
|
28
34
|
|
|
29
35
|
def print_result(result: Any, json_output: bool = False) -> None:
|
|
30
36
|
"""Print result in formatted or JSON mode."""
|
|
31
37
|
if json_output:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"metadata": result.metadata,
|
|
39
|
-
"debug_info": (
|
|
40
|
-
{
|
|
41
|
-
"request": result.debug_info.request,
|
|
42
|
-
"response": result.debug_info.response,
|
|
43
|
-
"duration_ms": result.debug_info.duration_ms,
|
|
44
|
-
}
|
|
45
|
-
if result.debug_info
|
|
46
|
-
else None
|
|
47
|
-
),
|
|
48
|
-
}
|
|
49
|
-
)
|
|
38
|
+
# Match JavaScript client: output just the data for scripting
|
|
39
|
+
if result.success and result.data:
|
|
40
|
+
print_json(result.data)
|
|
41
|
+
else:
|
|
42
|
+
# On error, output error info
|
|
43
|
+
print_json({"error": result.error, "success": False})
|
|
50
44
|
else:
|
|
51
|
-
|
|
45
|
+
# Pretty output with message and data (like JavaScript client)
|
|
52
46
|
if result.success:
|
|
47
|
+
print("\nSUCCESS\n")
|
|
48
|
+
# Show protocol message if available
|
|
49
|
+
if hasattr(result, "message") and result.message:
|
|
50
|
+
print("Protocol Message:")
|
|
51
|
+
print(result.message)
|
|
52
|
+
print()
|
|
53
53
|
if result.data:
|
|
54
|
-
print("
|
|
54
|
+
print("Response:")
|
|
55
55
|
print_json(result.data)
|
|
56
56
|
else:
|
|
57
|
+
print("\nFAILED\n")
|
|
57
58
|
print(f"Error: {result.error}")
|
|
58
59
|
|
|
59
60
|
|
|
@@ -49,6 +49,7 @@ class ProtocolAdapter(ABC):
|
|
|
49
49
|
return TaskResult[T](
|
|
50
50
|
status=raw_result.status,
|
|
51
51
|
data=None,
|
|
52
|
+
message=raw_result.message,
|
|
52
53
|
success=False,
|
|
53
54
|
error=raw_result.error or "No data returned from adapter",
|
|
54
55
|
metadata=raw_result.metadata,
|
|
@@ -66,6 +67,7 @@ class ProtocolAdapter(ABC):
|
|
|
66
67
|
return TaskResult[T](
|
|
67
68
|
status=raw_result.status,
|
|
68
69
|
data=parsed_data,
|
|
70
|
+
message=raw_result.message, # Preserve human-readable message from protocol
|
|
69
71
|
success=raw_result.success,
|
|
70
72
|
error=raw_result.error,
|
|
71
73
|
metadata=raw_result.metadata,
|
|
@@ -76,6 +78,7 @@ class ProtocolAdapter(ABC):
|
|
|
76
78
|
return TaskResult[T](
|
|
77
79
|
status=TaskStatus.FAILED,
|
|
78
80
|
error=f"Failed to parse response: {e}",
|
|
81
|
+
message=raw_result.message,
|
|
79
82
|
success=False,
|
|
80
83
|
debug_info=raw_result.debug_info,
|
|
81
84
|
)
|
|
@@ -186,6 +186,40 @@ class MCPAdapter(ProtocolAdapter):
|
|
|
186
186
|
else:
|
|
187
187
|
raise ValueError(f"Unsupported transport scheme: {parsed.scheme}")
|
|
188
188
|
|
|
189
|
+
def _serialize_mcp_content(self, content: list[Any]) -> list[dict[str, Any]]:
|
|
190
|
+
"""
|
|
191
|
+
Convert MCP SDK content objects to plain dicts.
|
|
192
|
+
|
|
193
|
+
The MCP SDK returns Pydantic objects (TextContent, ImageContent, etc.)
|
|
194
|
+
but the rest of the ADCP client expects protocol-agnostic dicts.
|
|
195
|
+
This method handles the translation at the protocol boundary.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
content: List of MCP content items (may be dicts or Pydantic objects)
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of plain dicts representing the content
|
|
202
|
+
"""
|
|
203
|
+
result = []
|
|
204
|
+
for item in content:
|
|
205
|
+
# Already a dict, pass through
|
|
206
|
+
if isinstance(item, dict):
|
|
207
|
+
result.append(item)
|
|
208
|
+
# Pydantic v2 model with model_dump()
|
|
209
|
+
elif hasattr(item, "model_dump"):
|
|
210
|
+
result.append(item.model_dump())
|
|
211
|
+
# Pydantic v1 model with dict()
|
|
212
|
+
elif hasattr(item, "dict") and callable(item.dict):
|
|
213
|
+
result.append(item.dict())
|
|
214
|
+
# Fallback: try to access __dict__
|
|
215
|
+
elif hasattr(item, "__dict__"):
|
|
216
|
+
result.append(dict(item.__dict__))
|
|
217
|
+
# Last resort: serialize as unknown type
|
|
218
|
+
else:
|
|
219
|
+
logger.warning(f"Unknown MCP content type: {type(item)}, serializing as string")
|
|
220
|
+
result.append({"type": "unknown", "data": str(item)})
|
|
221
|
+
return result
|
|
222
|
+
|
|
189
223
|
async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]:
|
|
190
224
|
"""Call a tool using MCP protocol."""
|
|
191
225
|
start_time = time.time() if self.agent_config.debug else None
|
|
@@ -205,22 +239,49 @@ class MCPAdapter(ProtocolAdapter):
|
|
|
205
239
|
# Call the tool using MCP client session
|
|
206
240
|
result = await session.call_tool(tool_name, params)
|
|
207
241
|
|
|
242
|
+
# This SDK requires MCP tools to return structuredContent
|
|
243
|
+
# The content field may contain human-readable messages but the actual
|
|
244
|
+
# response data must be in structuredContent
|
|
245
|
+
if not hasattr(result, "structuredContent") or result.structuredContent is None:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"MCP tool {tool_name} did not return structuredContent. "
|
|
248
|
+
f"This SDK requires MCP tools to provide structured responses. "
|
|
249
|
+
f"Got content: {result.content if hasattr(result, 'content') else 'none'}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Extract the structured data (required)
|
|
253
|
+
data_to_return = result.structuredContent
|
|
254
|
+
|
|
255
|
+
# Extract human-readable message from content (optional)
|
|
256
|
+
# This is typically a status message like "Found 42 creative formats"
|
|
257
|
+
message_text = None
|
|
258
|
+
if hasattr(result, "content") and result.content:
|
|
259
|
+
# Serialize content using the same method used for backward compatibility
|
|
260
|
+
serialized_content = self._serialize_mcp_content(result.content)
|
|
261
|
+
if isinstance(serialized_content, list):
|
|
262
|
+
for item in serialized_content:
|
|
263
|
+
is_text = isinstance(item, dict) and item.get("type") == "text"
|
|
264
|
+
if is_text and item.get("text"):
|
|
265
|
+
message_text = item["text"]
|
|
266
|
+
break
|
|
267
|
+
|
|
208
268
|
if self.agent_config.debug and start_time:
|
|
209
269
|
duration_ms = (time.time() - start_time) * 1000
|
|
210
270
|
debug_info = DebugInfo(
|
|
211
271
|
request=debug_request,
|
|
212
272
|
response={
|
|
213
|
-
"
|
|
273
|
+
"data": data_to_return,
|
|
274
|
+
"message": message_text,
|
|
214
275
|
"is_error": result.isError if hasattr(result, "isError") else False,
|
|
215
276
|
},
|
|
216
277
|
duration_ms=duration_ms,
|
|
217
278
|
)
|
|
218
279
|
|
|
219
|
-
#
|
|
220
|
-
# For AdCP, we expect the data in the content
|
|
280
|
+
# Return both the structured data and the human-readable message
|
|
221
281
|
return TaskResult[Any](
|
|
222
282
|
status=TaskStatus.COMPLETED,
|
|
223
|
-
data=
|
|
283
|
+
data=data_to_return,
|
|
284
|
+
message=message_text,
|
|
224
285
|
success=True,
|
|
225
286
|
debug_info=debug_info,
|
|
226
287
|
)
|
|
@@ -127,6 +127,7 @@ class TaskResult(BaseModel, Generic[T]):
|
|
|
127
127
|
|
|
128
128
|
status: TaskStatus
|
|
129
129
|
data: T | None = None
|
|
130
|
+
message: str | None = None # Human-readable message from agent (e.g., MCP content text)
|
|
130
131
|
submitted: SubmittedInfo | None = None
|
|
131
132
|
needs_input: NeedsInputInfo | None = None
|
|
132
133
|
error: str | None = None
|
|
@@ -20,10 +20,13 @@ def parse_mcp_content(content: list[dict[str, Any]], response_type: type[T]) ->
|
|
|
20
20
|
MCP tools return content as a list of content items:
|
|
21
21
|
[{"type": "text", "text": "..."}, {"type": "resource", ...}]
|
|
22
22
|
|
|
23
|
+
The MCP adapter is responsible for serializing MCP SDK Pydantic objects
|
|
24
|
+
to plain dicts before calling this function.
|
|
25
|
+
|
|
23
26
|
For AdCP, we expect JSON data in text content items.
|
|
24
27
|
|
|
25
28
|
Args:
|
|
26
|
-
content: MCP content array
|
|
29
|
+
content: MCP content array (list of plain dicts)
|
|
27
30
|
response_type: Expected Pydantic model type
|
|
28
31
|
|
|
29
32
|
Returns:
|
|
@@ -155,13 +155,15 @@ class TestMCPAdapter:
|
|
|
155
155
|
|
|
156
156
|
@pytest.mark.asyncio
|
|
157
157
|
async def test_call_tool_success(self, mcp_config):
|
|
158
|
-
"""Test successful tool call via MCP."""
|
|
158
|
+
"""Test successful tool call via MCP with proper structuredContent."""
|
|
159
159
|
adapter = MCPAdapter(mcp_config)
|
|
160
160
|
|
|
161
161
|
# Mock MCP session
|
|
162
162
|
mock_session = AsyncMock()
|
|
163
163
|
mock_result = MagicMock()
|
|
164
|
+
# Mock MCP result with structuredContent (required for AdCP)
|
|
164
165
|
mock_result.content = [{"type": "text", "text": "Success"}]
|
|
166
|
+
mock_result.structuredContent = {"products": [{"id": "prod1"}]}
|
|
165
167
|
mock_session.call_tool.return_value = mock_result
|
|
166
168
|
|
|
167
169
|
with patch.object(adapter, "_get_session", return_value=mock_session):
|
|
@@ -175,10 +177,53 @@ class TestMCPAdapter:
|
|
|
175
177
|
assert call_args[0][0] == "get_products"
|
|
176
178
|
assert call_args[0][1] == {"brief": "test"}
|
|
177
179
|
|
|
178
|
-
# Verify result
|
|
180
|
+
# Verify result uses structuredContent
|
|
181
|
+
assert result.success is True
|
|
182
|
+
assert result.status == TaskStatus.COMPLETED
|
|
183
|
+
assert result.data == {"products": [{"id": "prod1"}]}
|
|
184
|
+
|
|
185
|
+
@pytest.mark.asyncio
|
|
186
|
+
async def test_call_tool_with_structured_content(self, mcp_config):
|
|
187
|
+
"""Test successful tool call via MCP with structuredContent field."""
|
|
188
|
+
adapter = MCPAdapter(mcp_config)
|
|
189
|
+
|
|
190
|
+
# Mock MCP session
|
|
191
|
+
mock_session = AsyncMock()
|
|
192
|
+
mock_result = MagicMock()
|
|
193
|
+
# Mock MCP result with structuredContent (preferred over content)
|
|
194
|
+
mock_result.content = [{"type": "text", "text": "Found 42 creative formats"}]
|
|
195
|
+
mock_result.structuredContent = {"formats": [{"id": "format1"}, {"id": "format2"}]}
|
|
196
|
+
mock_session.call_tool.return_value = mock_result
|
|
197
|
+
|
|
198
|
+
with patch.object(adapter, "_get_session", return_value=mock_session):
|
|
199
|
+
result = await adapter._call_mcp_tool("list_creative_formats", {})
|
|
200
|
+
|
|
201
|
+
# Verify result uses structuredContent, not content array
|
|
179
202
|
assert result.success is True
|
|
180
203
|
assert result.status == TaskStatus.COMPLETED
|
|
181
|
-
assert result.data == [{"
|
|
204
|
+
assert result.data == {"formats": [{"id": "format1"}, {"id": "format2"}]}
|
|
205
|
+
# Verify message extraction from content array
|
|
206
|
+
assert result.message == "Found 42 creative formats"
|
|
207
|
+
|
|
208
|
+
@pytest.mark.asyncio
|
|
209
|
+
async def test_call_tool_missing_structured_content(self, mcp_config):
|
|
210
|
+
"""Test tool call fails when structuredContent is missing."""
|
|
211
|
+
adapter = MCPAdapter(mcp_config)
|
|
212
|
+
|
|
213
|
+
mock_session = AsyncMock()
|
|
214
|
+
mock_result = MagicMock()
|
|
215
|
+
# Mock MCP result WITHOUT structuredContent (invalid for AdCP)
|
|
216
|
+
mock_result.content = [{"type": "text", "text": "Success"}]
|
|
217
|
+
mock_result.structuredContent = None
|
|
218
|
+
mock_session.call_tool.return_value = mock_result
|
|
219
|
+
|
|
220
|
+
with patch.object(adapter, "_get_session", return_value=mock_session):
|
|
221
|
+
result = await adapter._call_mcp_tool("get_products", {"brief": "test"})
|
|
222
|
+
|
|
223
|
+
# Verify error handling for missing structuredContent
|
|
224
|
+
assert result.success is False
|
|
225
|
+
assert result.status == TaskStatus.FAILED
|
|
226
|
+
assert "did not return structuredContent" in result.error
|
|
182
227
|
|
|
183
228
|
@pytest.mark.asyncio
|
|
184
229
|
async def test_call_tool_error(self, mcp_config):
|
|
@@ -240,3 +285,59 @@ class TestMCPAdapter:
|
|
|
240
285
|
mock_exit_stack.aclose.assert_called_once()
|
|
241
286
|
assert adapter._exit_stack is None
|
|
242
287
|
assert adapter._session is None
|
|
288
|
+
|
|
289
|
+
def test_serialize_mcp_content_with_dicts(self, mcp_config):
|
|
290
|
+
"""Test serializing MCP content that's already dicts."""
|
|
291
|
+
adapter = MCPAdapter(mcp_config)
|
|
292
|
+
|
|
293
|
+
content = [
|
|
294
|
+
{"type": "text", "text": "Hello"},
|
|
295
|
+
{"type": "resource", "uri": "file://test.txt"},
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
result = adapter._serialize_mcp_content(content)
|
|
299
|
+
|
|
300
|
+
assert result == content # Pass through unchanged
|
|
301
|
+
assert len(result) == 2
|
|
302
|
+
|
|
303
|
+
def test_serialize_mcp_content_with_pydantic_v2(self, mcp_config):
|
|
304
|
+
"""Test serializing MCP content with Pydantic v2 objects."""
|
|
305
|
+
from pydantic import BaseModel
|
|
306
|
+
|
|
307
|
+
adapter = MCPAdapter(mcp_config)
|
|
308
|
+
|
|
309
|
+
class MockTextContent(BaseModel):
|
|
310
|
+
type: str
|
|
311
|
+
text: str
|
|
312
|
+
|
|
313
|
+
content = [
|
|
314
|
+
MockTextContent(type="text", text="Pydantic v2"),
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
result = adapter._serialize_mcp_content(content)
|
|
318
|
+
|
|
319
|
+
assert len(result) == 1
|
|
320
|
+
assert result[0] == {"type": "text", "text": "Pydantic v2"}
|
|
321
|
+
assert isinstance(result[0], dict)
|
|
322
|
+
|
|
323
|
+
def test_serialize_mcp_content_mixed(self, mcp_config):
|
|
324
|
+
"""Test serializing mixed MCP content (dicts and Pydantic objects)."""
|
|
325
|
+
from pydantic import BaseModel
|
|
326
|
+
|
|
327
|
+
adapter = MCPAdapter(mcp_config)
|
|
328
|
+
|
|
329
|
+
class MockTextContent(BaseModel):
|
|
330
|
+
type: str
|
|
331
|
+
text: str
|
|
332
|
+
|
|
333
|
+
content = [
|
|
334
|
+
{"type": "text", "text": "Plain dict"},
|
|
335
|
+
MockTextContent(type="text", text="Pydantic object"),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
result = adapter._serialize_mcp_content(content)
|
|
339
|
+
|
|
340
|
+
assert len(result) == 2
|
|
341
|
+
assert result[0] == {"type": "text", "text": "Plain dict"}
|
|
342
|
+
assert result[1] == {"type": "text", "text": "Pydantic object"}
|
|
343
|
+
assert all(isinstance(item, dict) for item in result)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|