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.
Files changed (33) hide show
  1. {adcp-1.0.3/src/adcp.egg-info → adcp-1.0.5}/PKG-INFO +1 -1
  2. {adcp-1.0.3 → adcp-1.0.5}/pyproject.toml +1 -1
  3. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/__init__.py +1 -1
  4. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/__main__.py +22 -21
  5. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/base.py +3 -0
  6. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/mcp.py +65 -4
  7. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/core.py +1 -0
  8. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/utils/response_parser.py +4 -1
  9. {adcp-1.0.3 → adcp-1.0.5/src/adcp.egg-info}/PKG-INFO +1 -1
  10. {adcp-1.0.3 → adcp-1.0.5}/tests/test_protocols.py +104 -3
  11. {adcp-1.0.3 → adcp-1.0.5}/LICENSE +0 -0
  12. {adcp-1.0.3 → adcp-1.0.5}/README.md +0 -0
  13. {adcp-1.0.3 → adcp-1.0.5}/setup.cfg +0 -0
  14. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/client.py +0 -0
  15. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/config.py +0 -0
  16. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/exceptions.py +0 -0
  17. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/__init__.py +0 -0
  18. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/protocols/a2a.py +0 -0
  19. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/__init__.py +0 -0
  20. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/generated.py +0 -0
  21. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/types/tasks.py +0 -0
  22. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/utils/__init__.py +0 -0
  23. {adcp-1.0.3 → adcp-1.0.5}/src/adcp/utils/operation_id.py +0 -0
  24. {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/SOURCES.txt +0 -0
  25. {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/dependency_links.txt +0 -0
  26. {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/entry_points.txt +0 -0
  27. {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/requires.txt +0 -0
  28. {adcp-1.0.3 → adcp-1.0.5}/src/adcp.egg-info/top_level.txt +0 -0
  29. {adcp-1.0.3 → adcp-1.0.5}/tests/test_cli.py +0 -0
  30. {adcp-1.0.3 → adcp-1.0.5}/tests/test_client.py +0 -0
  31. {adcp-1.0.3 → adcp-1.0.5}/tests/test_code_generation.py +0 -0
  32. {adcp-1.0.3 → adcp-1.0.5}/tests/test_format_id_validation.py +0 -0
  33. {adcp-1.0.3 → adcp-1.0.5}/tests/test_response_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adcp
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Official Python client for the Ad Context Protocol (AdCP)
5
5
  Author-email: AdCP Community <maintainers@adcontextprotocol.org>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adcp"
7
- version = "1.0.3"
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"}
@@ -46,7 +46,7 @@ from adcp.types.generated import (
46
46
  UpdateMediaBuyResponse,
47
47
  )
48
48
 
49
- __version__ = "1.0.3"
49
+ __version__ = "1.0.5"
50
50
 
51
51
  __all__ = [
52
52
  # Client classes
@@ -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
- print(json.dumps(data, indent=2, default=str))
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
- print_json(
33
- {
34
- "status": result.status.value,
35
- "success": result.success,
36
- "data": result.data,
37
- "error": result.error,
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
- print(f"\nStatus: {result.status.value}")
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("\nResult:")
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
- "content": result.content,
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
- # MCP tool results contain a list of content items
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=result.content,
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adcp
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Official Python client for the Ad Context Protocol (AdCP)
5
5
  Author-email: AdCP Community <maintainers@adcontextprotocol.org>
6
6
  License: Apache-2.0
@@ -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 parsing
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 == [{"type": "text", "text": "Success"}]
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