adcp 1.0.4__tar.gz → 1.1.0__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 (35) hide show
  1. {adcp-1.0.4/src/adcp.egg-info → adcp-1.1.0}/PKG-INFO +61 -16
  2. {adcp-1.0.4 → adcp-1.1.0}/README.md +60 -15
  3. {adcp-1.0.4 → adcp-1.1.0}/pyproject.toml +1 -1
  4. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/__init__.py +1 -1
  5. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/__main__.py +22 -21
  6. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/client.py +96 -5
  7. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/protocols/a2a.py +4 -0
  8. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/protocols/base.py +3 -0
  9. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/protocols/mcp.py +71 -47
  10. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/types/core.py +4 -4
  11. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/types/generated.py +58 -35
  12. adcp-1.1.0/src/adcp/utils/preview_cache.py +461 -0
  13. {adcp-1.0.4 → adcp-1.1.0/src/adcp.egg-info}/PKG-INFO +61 -16
  14. {adcp-1.0.4 → adcp-1.1.0}/src/adcp.egg-info/SOURCES.txt +2 -0
  15. {adcp-1.0.4 → adcp-1.1.0}/tests/test_client.py +107 -0
  16. adcp-1.1.0/tests/test_preview_html.py +376 -0
  17. {adcp-1.0.4 → adcp-1.1.0}/tests/test_protocols.py +161 -3
  18. {adcp-1.0.4 → adcp-1.1.0}/LICENSE +0 -0
  19. {adcp-1.0.4 → adcp-1.1.0}/setup.cfg +0 -0
  20. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/config.py +0 -0
  21. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/exceptions.py +0 -0
  22. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/protocols/__init__.py +0 -0
  23. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/types/__init__.py +0 -0
  24. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/types/tasks.py +0 -0
  25. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/utils/__init__.py +0 -0
  26. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/utils/operation_id.py +0 -0
  27. {adcp-1.0.4 → adcp-1.1.0}/src/adcp/utils/response_parser.py +0 -0
  28. {adcp-1.0.4 → adcp-1.1.0}/src/adcp.egg-info/dependency_links.txt +0 -0
  29. {adcp-1.0.4 → adcp-1.1.0}/src/adcp.egg-info/entry_points.txt +0 -0
  30. {adcp-1.0.4 → adcp-1.1.0}/src/adcp.egg-info/requires.txt +0 -0
  31. {adcp-1.0.4 → adcp-1.1.0}/src/adcp.egg-info/top_level.txt +0 -0
  32. {adcp-1.0.4 → adcp-1.1.0}/tests/test_cli.py +0 -0
  33. {adcp-1.0.4 → adcp-1.1.0}/tests/test_code_generation.py +0 -0
  34. {adcp-1.0.4 → adcp-1.1.0}/tests/test_format_id_validation.py +0 -0
  35. {adcp-1.0.4 → adcp-1.1.0}/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.4
3
+ Version: 1.1.0
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
@@ -64,8 +64,8 @@ pip install adcp
64
64
  ```python
65
65
  from adcp import ADCPMultiAgentClient, AgentConfig, GetProductsRequest
66
66
 
67
- # Configure agents and handlers
68
- client = ADCPMultiAgentClient(
67
+ # Configure agents and handlers (context manager ensures proper cleanup)
68
+ async with ADCPMultiAgentClient(
69
69
  agents=[
70
70
  AgentConfig(
71
71
  id="agent_x",
@@ -91,21 +91,21 @@ client = ADCPMultiAgentClient(
91
91
  if metadata.status == "completed" else None
92
92
  )
93
93
  }
94
- )
95
-
96
- # Execute operation - library handles operation IDs, webhook URLs, context management
97
- agent = client.agent("agent_x")
98
- request = GetProductsRequest(brief="Coffee brands")
99
- result = await agent.get_products(request)
94
+ ) as client:
95
+ # Execute operation - library handles operation IDs, webhook URLs, context management
96
+ agent = client.agent("agent_x")
97
+ request = GetProductsRequest(brief="Coffee brands")
98
+ result = await agent.get_products(request)
100
99
 
101
- # Check result
102
- if result.status == "completed":
103
- # Agent completed synchronously!
104
- print(f"✅ Sync completion: {len(result.data.products)} products")
100
+ # Check result
101
+ if result.status == "completed":
102
+ # Agent completed synchronously!
103
+ print(f"✅ Sync completion: {len(result.data.products)} products")
105
104
 
106
- if result.status == "submitted":
107
- # Agent will send webhook when complete
108
- print(f"⏳ Async - webhook registered at: {result.submitted.webhook_url}")
105
+ if result.status == "submitted":
106
+ # Agent will send webhook when complete
107
+ print(f"⏳ Async - webhook registered at: {result.submitted.webhook_url}")
108
+ # Connections automatically cleaned up here
109
109
  ```
110
110
 
111
111
  ## Features
@@ -210,6 +210,51 @@ Or use the CLI:
210
210
  uvx adcp --debug myagent get_products '{"brief":"TV ads"}'
211
211
  ```
212
212
 
213
+ ### Resource Management
214
+
215
+ **Why use async context managers?**
216
+ - Ensures HTTP connections are properly closed, preventing resource leaks
217
+ - Handles cleanup even when exceptions occur
218
+ - Required for production applications with connection pooling
219
+ - Prevents issues with async task group cleanup in MCP protocol
220
+
221
+ The recommended pattern uses async context managers:
222
+
223
+ ```python
224
+ from adcp import ADCPClient, AgentConfig, GetProductsRequest
225
+
226
+ # Recommended: Automatic cleanup with context manager
227
+ config = AgentConfig(id="agent_x", agent_uri="https://...", protocol="a2a")
228
+ async with ADCPClient(config) as client:
229
+ request = GetProductsRequest(brief="Coffee brands")
230
+ result = await client.get_products(request)
231
+ # Connection automatically closed on exit
232
+
233
+ # Multi-agent client also supports context managers
234
+ async with ADCPMultiAgentClient(agents) as client:
235
+ # Execute across all agents in parallel
236
+ results = await client.get_products(request)
237
+ # All agent connections closed automatically (even if some failed)
238
+ ```
239
+
240
+ Manual cleanup is available for special cases (e.g., managing client lifecycle manually):
241
+
242
+ ```python
243
+ # Use manual cleanup when you need fine-grained control over lifecycle
244
+ client = ADCPClient(config)
245
+ try:
246
+ result = await client.get_products(request)
247
+ finally:
248
+ await client.close() # Explicit cleanup
249
+ ```
250
+
251
+ **When to use manual cleanup:**
252
+ - Managing client lifecycle across multiple functions
253
+ - Testing scenarios requiring explicit control
254
+ - Integration with frameworks that manage resources differently
255
+
256
+ In most cases, prefer the context manager pattern.
257
+
213
258
  ### Error Handling
214
259
 
215
260
  The library provides a comprehensive exception hierarchy with helpful error messages:
@@ -27,8 +27,8 @@ pip install adcp
27
27
  ```python
28
28
  from adcp import ADCPMultiAgentClient, AgentConfig, GetProductsRequest
29
29
 
30
- # Configure agents and handlers
31
- client = ADCPMultiAgentClient(
30
+ # Configure agents and handlers (context manager ensures proper cleanup)
31
+ async with ADCPMultiAgentClient(
32
32
  agents=[
33
33
  AgentConfig(
34
34
  id="agent_x",
@@ -54,21 +54,21 @@ client = ADCPMultiAgentClient(
54
54
  if metadata.status == "completed" else None
55
55
  )
56
56
  }
57
- )
58
-
59
- # Execute operation - library handles operation IDs, webhook URLs, context management
60
- agent = client.agent("agent_x")
61
- request = GetProductsRequest(brief="Coffee brands")
62
- result = await agent.get_products(request)
57
+ ) as client:
58
+ # Execute operation - library handles operation IDs, webhook URLs, context management
59
+ agent = client.agent("agent_x")
60
+ request = GetProductsRequest(brief="Coffee brands")
61
+ result = await agent.get_products(request)
63
62
 
64
- # Check result
65
- if result.status == "completed":
66
- # Agent completed synchronously!
67
- print(f"✅ Sync completion: {len(result.data.products)} products")
63
+ # Check result
64
+ if result.status == "completed":
65
+ # Agent completed synchronously!
66
+ print(f"✅ Sync completion: {len(result.data.products)} products")
68
67
 
69
- if result.status == "submitted":
70
- # Agent will send webhook when complete
71
- print(f"⏳ Async - webhook registered at: {result.submitted.webhook_url}")
68
+ if result.status == "submitted":
69
+ # Agent will send webhook when complete
70
+ print(f"⏳ Async - webhook registered at: {result.submitted.webhook_url}")
71
+ # Connections automatically cleaned up here
72
72
  ```
73
73
 
74
74
  ## Features
@@ -173,6 +173,51 @@ Or use the CLI:
173
173
  uvx adcp --debug myagent get_products '{"brief":"TV ads"}'
174
174
  ```
175
175
 
176
+ ### Resource Management
177
+
178
+ **Why use async context managers?**
179
+ - Ensures HTTP connections are properly closed, preventing resource leaks
180
+ - Handles cleanup even when exceptions occur
181
+ - Required for production applications with connection pooling
182
+ - Prevents issues with async task group cleanup in MCP protocol
183
+
184
+ The recommended pattern uses async context managers:
185
+
186
+ ```python
187
+ from adcp import ADCPClient, AgentConfig, GetProductsRequest
188
+
189
+ # Recommended: Automatic cleanup with context manager
190
+ config = AgentConfig(id="agent_x", agent_uri="https://...", protocol="a2a")
191
+ async with ADCPClient(config) as client:
192
+ request = GetProductsRequest(brief="Coffee brands")
193
+ result = await client.get_products(request)
194
+ # Connection automatically closed on exit
195
+
196
+ # Multi-agent client also supports context managers
197
+ async with ADCPMultiAgentClient(agents) as client:
198
+ # Execute across all agents in parallel
199
+ results = await client.get_products(request)
200
+ # All agent connections closed automatically (even if some failed)
201
+ ```
202
+
203
+ Manual cleanup is available for special cases (e.g., managing client lifecycle manually):
204
+
205
+ ```python
206
+ # Use manual cleanup when you need fine-grained control over lifecycle
207
+ client = ADCPClient(config)
208
+ try:
209
+ result = await client.get_products(request)
210
+ finally:
211
+ await client.close() # Explicit cleanup
212
+ ```
213
+
214
+ **When to use manual cleanup:**
215
+ - Managing client lifecycle across multiple functions
216
+ - Testing scenarios requiring explicit control
217
+ - Integration with frameworks that manage resources differently
218
+
219
+ In most cases, prefer the context manager pattern.
220
+
176
221
  ### Error Handling
177
222
 
178
223
  The library provides a comprehensive exception hierarchy with helpful error messages:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adcp"
7
- version = "1.0.4"
7
+ version = "1.1.0"
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.4"
49
+ __version__ = "1.1.0"
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
 
@@ -37,6 +37,8 @@ from adcp.types.generated import (
37
37
  ListCreativeFormatsResponse,
38
38
  ListCreativesRequest,
39
39
  ListCreativesResponse,
40
+ PreviewCreativeRequest,
41
+ PreviewCreativeResponse,
40
42
  ProvidePerformanceFeedbackRequest,
41
43
  ProvidePerformanceFeedbackResponse,
42
44
  SyncCreativesRequest,
@@ -101,16 +103,31 @@ class ADCPClient:
101
103
  async def get_products(
102
104
  self,
103
105
  request: GetProductsRequest,
106
+ fetch_previews: bool = False,
107
+ preview_output_format: str = "url",
108
+ creative_agent_client: ADCPClient | None = None,
104
109
  ) -> TaskResult[GetProductsResponse]:
105
110
  """
106
111
  Get advertising products.
107
112
 
108
113
  Args:
109
114
  request: Request parameters
115
+ fetch_previews: If True, generate preview URLs for each product's formats
116
+ (uses batch API for 5-10x performance improvement)
117
+ preview_output_format: "url" for iframe URLs (default), "html" for direct
118
+ embedding (2-3x faster, no iframe overhead)
119
+ creative_agent_client: Client for creative agent (required if
120
+ fetch_previews=True)
110
121
 
111
122
  Returns:
112
- TaskResult containing GetProductsResponse
123
+ TaskResult containing GetProductsResponse with optional preview URLs in metadata
124
+
125
+ Raises:
126
+ ValueError: If fetch_previews=True but creative_agent_client is not provided
113
127
  """
128
+ if fetch_previews and not creative_agent_client:
129
+ raise ValueError("creative_agent_client is required when fetch_previews=True")
130
+
114
131
  operation_id = create_operation_id()
115
132
  params = request.model_dump(exclude_none=True)
116
133
 
@@ -137,20 +154,40 @@ class ADCPClient:
137
154
  )
138
155
  )
139
156
 
140
- return self.adapter._parse_response(raw_result, GetProductsResponse)
157
+ result = self.adapter._parse_response(raw_result, GetProductsResponse)
158
+
159
+ if fetch_previews and result.success and result.data and creative_agent_client:
160
+ from adcp.utils.preview_cache import add_preview_urls_to_products
161
+
162
+ products_with_previews = await add_preview_urls_to_products(
163
+ result.data.products,
164
+ creative_agent_client,
165
+ use_batch=True,
166
+ output_format=preview_output_format,
167
+ )
168
+ result.metadata = result.metadata or {}
169
+ result.metadata["products_with_previews"] = products_with_previews
170
+
171
+ return result
141
172
 
142
173
  async def list_creative_formats(
143
174
  self,
144
175
  request: ListCreativeFormatsRequest,
176
+ fetch_previews: bool = False,
177
+ preview_output_format: str = "url",
145
178
  ) -> TaskResult[ListCreativeFormatsResponse]:
146
179
  """
147
180
  List supported creative formats.
148
181
 
149
182
  Args:
150
183
  request: Request parameters
184
+ fetch_previews: If True, generate preview URLs for each format using
185
+ sample manifests (uses batch API for 5-10x performance improvement)
186
+ preview_output_format: "url" for iframe URLs (default), "html" for direct
187
+ embedding (2-3x faster, no iframe overhead)
151
188
 
152
189
  Returns:
153
- TaskResult containing ListCreativeFormatsResponse
190
+ TaskResult containing ListCreativeFormatsResponse with optional preview URLs in metadata
154
191
  """
155
192
  operation_id = create_operation_id()
156
193
  params = request.model_dump(exclude_none=True)
@@ -178,8 +215,62 @@ class ADCPClient:
178
215
  )
179
216
  )
180
217
 
181
- # Parse response using adapter's helper
182
- return self.adapter._parse_response(raw_result, ListCreativeFormatsResponse)
218
+ result = self.adapter._parse_response(raw_result, ListCreativeFormatsResponse)
219
+
220
+ if fetch_previews and result.success and result.data:
221
+ from adcp.utils.preview_cache import add_preview_urls_to_formats
222
+
223
+ formats_with_previews = await add_preview_urls_to_formats(
224
+ result.data.formats,
225
+ self,
226
+ use_batch=True,
227
+ output_format=preview_output_format,
228
+ )
229
+ result.metadata = result.metadata or {}
230
+ result.metadata["formats_with_previews"] = formats_with_previews
231
+
232
+ return result
233
+
234
+ async def preview_creative(
235
+ self,
236
+ request: PreviewCreativeRequest,
237
+ ) -> TaskResult[PreviewCreativeResponse]:
238
+ """
239
+ Generate preview of a creative manifest.
240
+
241
+ Args:
242
+ request: Request parameters
243
+
244
+ Returns:
245
+ TaskResult containing PreviewCreativeResponse with preview URLs
246
+ """
247
+ operation_id = create_operation_id()
248
+ params = request.model_dump(exclude_none=True)
249
+
250
+ self._emit_activity(
251
+ Activity(
252
+ type=ActivityType.PROTOCOL_REQUEST,
253
+ operation_id=operation_id,
254
+ agent_id=self.agent_config.id,
255
+ task_type="preview_creative",
256
+ timestamp=datetime.now(timezone.utc).isoformat(),
257
+ )
258
+ )
259
+
260
+ raw_result = await self.adapter.preview_creative(params) # type: ignore[attr-defined]
261
+
262
+ self._emit_activity(
263
+ Activity(
264
+ type=ActivityType.PROTOCOL_RESPONSE,
265
+ operation_id=operation_id,
266
+ agent_id=self.agent_config.id,
267
+ task_type="preview_creative",
268
+ status=raw_result.status,
269
+ timestamp=datetime.now(timezone.utc).isoformat(),
270
+ )
271
+ )
272
+
273
+ return self.adapter._parse_response(raw_result, PreviewCreativeResponse)
183
274
 
184
275
  async def sync_creatives(
185
276
  self,
@@ -244,6 +244,10 @@ class A2AAdapter(ProtocolAdapter):
244
244
  """Provide performance feedback."""
245
245
  return await self._call_a2a_tool("provide_performance_feedback", params)
246
246
 
247
+ async def preview_creative(self, params: dict[str, Any]) -> TaskResult[Any]:
248
+ """Generate preview URLs for a creative manifest."""
249
+ return await self._call_a2a_tool("preview_creative", params)
250
+
247
251
  async def list_tools(self) -> list[str]:
248
252
  """
249
253
  List available tools from A2A agent.
@@ -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
  )
@@ -40,6 +40,39 @@ class MCPAdapter(ProtocolAdapter):
40
40
  self._session: Any = None
41
41
  self._exit_stack: Any = None
42
42
 
43
+ async def _cleanup_failed_connection(self, context: str) -> None:
44
+ """
45
+ Clean up resources after a failed connection attempt.
46
+
47
+ This method handles cleanup without raising exceptions to avoid
48
+ masking the original connection error.
49
+
50
+ Args:
51
+ context: Description of the context for logging (e.g., "during connection attempt")
52
+ """
53
+ if self._exit_stack is not None:
54
+ old_stack = self._exit_stack
55
+ self._exit_stack = None
56
+ self._session = None
57
+ try:
58
+ await old_stack.aclose()
59
+ except asyncio.CancelledError:
60
+ logger.debug(f"MCP session cleanup cancelled {context}")
61
+ except RuntimeError as cleanup_error:
62
+ # Known anyio task group cleanup issue
63
+ error_msg = str(cleanup_error).lower()
64
+ if "cancel scope" in error_msg or "async context" in error_msg:
65
+ logger.debug(f"Ignoring anyio cleanup error {context}: {cleanup_error}")
66
+ else:
67
+ logger.warning(
68
+ f"Unexpected RuntimeError during cleanup {context}: {cleanup_error}"
69
+ )
70
+ except Exception as cleanup_error:
71
+ # Log unexpected cleanup errors but don't raise to preserve original error
72
+ logger.warning(
73
+ f"Unexpected error during cleanup {context}: {cleanup_error}", exc_info=True
74
+ )
75
+
43
76
  async def _get_session(self) -> ClientSession:
44
77
  """
45
78
  Get or create MCP client session with URL fallback handling.
@@ -115,35 +148,8 @@ class MCPAdapter(ProtocolAdapter):
115
148
  return self._session # type: ignore[no-any-return]
116
149
  except Exception as e:
117
150
  last_error = e
118
- # Clean up the exit stack on failure to avoid async scope issues
119
- if self._exit_stack is not None:
120
- old_stack = self._exit_stack
121
- self._exit_stack = None # Clear immediately to prevent reuse
122
- self._session = None
123
- try:
124
- await old_stack.aclose()
125
- except asyncio.CancelledError:
126
- # Expected during shutdown
127
- pass
128
- except RuntimeError as cleanup_error:
129
- # Known MCP SDK async cleanup issue
130
- if (
131
- "async context" in str(cleanup_error).lower()
132
- or "cancel scope" in str(cleanup_error).lower()
133
- ):
134
- logger.debug(
135
- "Ignoring MCP SDK async context error during cleanup: "
136
- f"{cleanup_error}"
137
- )
138
- else:
139
- logger.warning(
140
- f"Unexpected RuntimeError during cleanup: {cleanup_error}"
141
- )
142
- except Exception as cleanup_error:
143
- # Unexpected cleanup errors should be logged
144
- logger.warning(
145
- f"Unexpected error during cleanup: {cleanup_error}", exc_info=True
146
- )
151
+ # Clean up the exit stack on failure to avoid resource leaks
152
+ await self._cleanup_failed_connection("during connection attempt")
147
153
 
148
154
  # If this isn't the last URL to try, create a new exit stack and continue
149
155
  if url != urls_to_try[-1]:
@@ -239,25 +245,49 @@ class MCPAdapter(ProtocolAdapter):
239
245
  # Call the tool using MCP client session
240
246
  result = await session.call_tool(tool_name, params)
241
247
 
242
- # Serialize MCP SDK types to plain dicts at protocol boundary
243
- serialized_content = self._serialize_mcp_content(result.content)
248
+ # This SDK requires MCP tools to return structuredContent
249
+ # The content field may contain human-readable messages but the actual
250
+ # response data must be in structuredContent
251
+ if not hasattr(result, "structuredContent") or result.structuredContent is None:
252
+ raise ValueError(
253
+ f"MCP tool {tool_name} did not return structuredContent. "
254
+ f"This SDK requires MCP tools to provide structured responses. "
255
+ f"Got content: {result.content if hasattr(result, 'content') else 'none'}"
256
+ )
257
+
258
+ # Extract the structured data (required)
259
+ data_to_return = result.structuredContent
260
+
261
+ # Extract human-readable message from content (optional)
262
+ # This is typically a status message like "Found 42 creative formats"
263
+ message_text = None
264
+ if hasattr(result, "content") and result.content:
265
+ # Serialize content using the same method used for backward compatibility
266
+ serialized_content = self._serialize_mcp_content(result.content)
267
+ if isinstance(serialized_content, list):
268
+ for item in serialized_content:
269
+ is_text = isinstance(item, dict) and item.get("type") == "text"
270
+ if is_text and item.get("text"):
271
+ message_text = item["text"]
272
+ break
244
273
 
245
274
  if self.agent_config.debug and start_time:
246
275
  duration_ms = (time.time() - start_time) * 1000
247
276
  debug_info = DebugInfo(
248
277
  request=debug_request,
249
278
  response={
250
- "content": serialized_content,
279
+ "data": data_to_return,
280
+ "message": message_text,
251
281
  "is_error": result.isError if hasattr(result, "isError") else False,
252
282
  },
253
283
  duration_ms=duration_ms,
254
284
  )
255
285
 
256
- # MCP tool results contain a list of content items
257
- # For AdCP, we expect the data in the content
286
+ # Return both the structured data and the human-readable message
258
287
  return TaskResult[Any](
259
288
  status=TaskStatus.COMPLETED,
260
- data=serialized_content,
289
+ data=data_to_return,
290
+ message=message_text,
261
291
  success=True,
262
292
  debug_info=debug_info,
263
293
  )
@@ -317,6 +347,10 @@ class MCPAdapter(ProtocolAdapter):
317
347
  """Provide performance feedback."""
318
348
  return await self._call_mcp_tool("provide_performance_feedback", params)
319
349
 
350
+ async def preview_creative(self, params: dict[str, Any]) -> TaskResult[Any]:
351
+ """Generate preview URLs for a creative manifest."""
352
+ return await self._call_mcp_tool("preview_creative", params)
353
+
320
354
  async def list_tools(self) -> list[str]:
321
355
  """List available tools from MCP agent."""
322
356
  session = await self._get_session()
@@ -324,15 +358,5 @@ class MCPAdapter(ProtocolAdapter):
324
358
  return [tool.name for tool in result.tools]
325
359
 
326
360
  async def close(self) -> None:
327
- """Close the MCP session."""
328
- if self._exit_stack is not None:
329
- old_stack = self._exit_stack
330
- self._exit_stack = None
331
- self._session = None
332
- try:
333
- await old_stack.aclose()
334
- except (asyncio.CancelledError, RuntimeError):
335
- # Cleanup errors during shutdown are expected
336
- pass
337
- except Exception as e:
338
- logger.debug(f"Error during MCP session cleanup: {e}")
361
+ """Close the MCP session and clean up resources."""
362
+ await self._cleanup_failed_connection("during close")
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from enum import Enum
6
6
  from typing import Any, Generic, Literal, TypeVar
7
7
 
8
- from pydantic import BaseModel, Field, field_validator
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
9
 
10
10
 
11
11
  class Protocol(str, Enum):
@@ -125,8 +125,11 @@ class DebugInfo(BaseModel):
125
125
  class TaskResult(BaseModel, Generic[T]):
126
126
  """Result from task execution."""
127
127
 
128
+ model_config = ConfigDict(arbitrary_types_allowed=True)
129
+
128
130
  status: TaskStatus
129
131
  data: T | None = None
132
+ message: str | None = None # Human-readable message from agent (e.g., MCP content text)
130
133
  submitted: SubmittedInfo | None = None
131
134
  needs_input: NeedsInputInfo | None = None
132
135
  error: str | None = None
@@ -134,9 +137,6 @@ class TaskResult(BaseModel, Generic[T]):
134
137
  metadata: dict[str, Any] | None = None
135
138
  debug_info: DebugInfo | None = None
136
139
 
137
- class Config:
138
- arbitrary_types_allowed = True
139
-
140
140
 
141
141
  class ActivityType(str, Enum):
142
142
  """Types of activity events."""