adcp 0.1.2__py3-none-any.whl → 1.0.1__py3-none-any.whl

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/protocols/mcp.py CHANGED
@@ -1,19 +1,31 @@
1
+ from __future__ import annotations
2
+
1
3
  """MCP protocol adapter using official Python MCP SDK."""
2
4
 
3
- from typing import Any
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from contextlib import AsyncExitStack
9
+ from typing import TYPE_CHECKING, Any
4
10
  from urllib.parse import urlparse
5
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
+ if TYPE_CHECKING:
15
+ from mcp import ClientSession
16
+
6
17
  try:
7
- from mcp import ClientSession # type: ignore[import-not-found]
8
- from mcp.client.sse import sse_client # type: ignore[import-not-found]
18
+ from mcp import ClientSession as _ClientSession
19
+ from mcp.client.sse import sse_client
20
+ from mcp.client.streamable_http import streamablehttp_client
9
21
 
10
22
  MCP_AVAILABLE = True
11
23
  except ImportError:
12
24
  MCP_AVAILABLE = False
13
- ClientSession = None
14
25
 
26
+ from adcp.exceptions import ADCPConnectionError, ADCPTimeoutError
15
27
  from adcp.protocols.base import ProtocolAdapter
16
- from adcp.types.core import TaskResult, TaskStatus
28
+ from adcp.types.core import DebugInfo, TaskResult, TaskStatus
17
29
 
18
30
 
19
31
  class MCPAdapter(ProtocolAdapter):
@@ -29,73 +41,221 @@ class MCPAdapter(ProtocolAdapter):
29
41
  self._exit_stack: Any = None
30
42
 
31
43
  async def _get_session(self) -> ClientSession:
32
- """Get or create MCP client session."""
44
+ """
45
+ Get or create MCP client session with URL fallback handling.
46
+
47
+ Raises:
48
+ ADCPConnectionError: If connection to agent fails
49
+ """
33
50
  if self._session is not None:
34
- return self._session
51
+ return self._session # type: ignore[no-any-return]
52
+
53
+ logger.debug(f"Creating MCP session for agent {self.agent_config.id}")
35
54
 
36
55
  # Parse the agent URI to determine transport type
37
56
  parsed = urlparse(self.agent_config.agent_uri)
38
57
 
39
58
  # Use SSE transport for HTTP/HTTPS endpoints
40
59
  if parsed.scheme in ("http", "https"):
41
- from contextlib import AsyncExitStack
42
-
43
60
  self._exit_stack = AsyncExitStack()
44
61
 
45
62
  # Create SSE client with authentication header
46
63
  headers = {}
47
64
  if self.agent_config.auth_token:
48
- headers["x-adcp-auth"] = self.agent_config.auth_token
65
+ # Support custom auth headers and types
66
+ if self.agent_config.auth_type == "bearer":
67
+ headers[self.agent_config.auth_header] = (
68
+ f"Bearer {self.agent_config.auth_token}"
69
+ )
70
+ else:
71
+ headers[self.agent_config.auth_header] = self.agent_config.auth_token
49
72
 
50
- read, write = await self._exit_stack.enter_async_context(
51
- sse_client(self.agent_config.agent_uri, headers=headers)
52
- )
73
+ # Try the user's exact URL first
74
+ urls_to_try = [self.agent_config.agent_uri]
75
+
76
+ # If URL doesn't end with /mcp, also try with /mcp suffix
77
+ if not self.agent_config.agent_uri.rstrip("/").endswith("/mcp"):
78
+ base_uri = self.agent_config.agent_uri.rstrip("/")
79
+ urls_to_try.append(f"{base_uri}/mcp")
80
+
81
+ last_error = None
82
+ for url in urls_to_try:
83
+ try:
84
+ # Choose transport based on configuration
85
+ if self.agent_config.mcp_transport == "streamable_http":
86
+ # Use streamable HTTP transport (newer, bidirectional)
87
+ read, write, _get_session_id = await self._exit_stack.enter_async_context(
88
+ streamablehttp_client(
89
+ url, headers=headers, timeout=self.agent_config.timeout
90
+ )
91
+ )
92
+ else:
93
+ # Use SSE transport (legacy, but widely supported)
94
+ read, write = await self._exit_stack.enter_async_context(
95
+ sse_client(url, headers=headers)
96
+ )
97
+
98
+ self._session = await self._exit_stack.enter_async_context(
99
+ _ClientSession(read, write)
100
+ )
53
101
 
54
- self._session = await self._exit_stack.enter_async_context(ClientSession(read, write))
102
+ # Initialize the session
103
+ await self._session.initialize()
55
104
 
56
- # Initialize the session
57
- await self._session.initialize()
105
+ logger.info(
106
+ f"Connected to MCP agent {self.agent_config.id} at {url} "
107
+ f"using {self.agent_config.mcp_transport} transport"
108
+ )
109
+ if url != self.agent_config.agent_uri:
110
+ logger.info(
111
+ f"Note: Connected using fallback URL {url} "
112
+ f"(configured: {self.agent_config.agent_uri})"
113
+ )
58
114
 
59
- return self._session
115
+ return self._session # type: ignore[no-any-return]
116
+ except Exception as e:
117
+ 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
+ )
147
+
148
+ # If this isn't the last URL to try, create a new exit stack and continue
149
+ if url != urls_to_try[-1]:
150
+ logger.debug(f"Retrying with next URL after error: {last_error}")
151
+ self._exit_stack = AsyncExitStack()
152
+ continue
153
+ # If this was the last URL, raise the error
154
+ logger.error(
155
+ f"Failed to connect to MCP agent {self.agent_config.id} using "
156
+ f"{self.agent_config.mcp_transport} transport. "
157
+ f"Tried URLs: {', '.join(urls_to_try)}"
158
+ )
159
+
160
+ # Classify error type for better exception handling
161
+ error_str = str(last_error).lower()
162
+ if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
163
+ from adcp.exceptions import ADCPAuthenticationError
164
+
165
+ raise ADCPAuthenticationError(
166
+ f"Authentication failed: {last_error}",
167
+ agent_id=self.agent_config.id,
168
+ agent_uri=self.agent_config.agent_uri,
169
+ ) from last_error
170
+ elif "timeout" in error_str:
171
+ raise ADCPTimeoutError(
172
+ f"Connection timeout: {last_error}",
173
+ agent_id=self.agent_config.id,
174
+ agent_uri=self.agent_config.agent_uri,
175
+ timeout=self.agent_config.timeout,
176
+ ) from last_error
177
+ else:
178
+ raise ADCPConnectionError(
179
+ f"Failed to connect: {last_error}",
180
+ agent_id=self.agent_config.id,
181
+ agent_uri=self.agent_config.agent_uri,
182
+ ) from last_error
183
+
184
+ # This shouldn't be reached, but just in case
185
+ raise RuntimeError(f"Failed to connect to MCP agent at {self.agent_config.agent_uri}")
60
186
  else:
61
187
  raise ValueError(f"Unsupported transport scheme: {parsed.scheme}")
62
188
 
63
189
  async def call_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]:
64
190
  """Call a tool using MCP protocol."""
191
+ start_time = time.time() if self.agent_config.debug else None
192
+ debug_info = None
193
+
65
194
  try:
66
195
  session = await self._get_session()
67
196
 
197
+ if self.agent_config.debug:
198
+ debug_request = {
199
+ "protocol": "MCP",
200
+ "tool": tool_name,
201
+ "params": params,
202
+ "transport": self.agent_config.mcp_transport,
203
+ }
204
+
68
205
  # Call the tool using MCP client session
69
206
  result = await session.call_tool(tool_name, params)
70
207
 
208
+ if self.agent_config.debug and start_time:
209
+ duration_ms = (time.time() - start_time) * 1000
210
+ debug_info = DebugInfo(
211
+ request=debug_request,
212
+ response={
213
+ "content": result.content,
214
+ "is_error": result.isError if hasattr(result, "isError") else False,
215
+ },
216
+ duration_ms=duration_ms,
217
+ )
218
+
71
219
  # MCP tool results contain a list of content items
72
220
  # For AdCP, we expect the data in the content
73
221
  return TaskResult[Any](
74
222
  status=TaskStatus.COMPLETED,
75
223
  data=result.content,
76
224
  success=True,
225
+ debug_info=debug_info,
77
226
  )
78
227
 
79
228
  except Exception as e:
229
+ if self.agent_config.debug and start_time:
230
+ duration_ms = (time.time() - start_time) * 1000
231
+ debug_info = DebugInfo(
232
+ request=debug_request if self.agent_config.debug else {},
233
+ response={"error": str(e)},
234
+ duration_ms=duration_ms,
235
+ )
80
236
  return TaskResult[Any](
81
237
  status=TaskStatus.FAILED,
82
238
  error=str(e),
83
239
  success=False,
240
+ debug_info=debug_info,
84
241
  )
85
242
 
86
243
  async def list_tools(self) -> list[str]:
87
244
  """List available tools from MCP agent."""
88
- try:
89
- session = await self._get_session()
90
- result = await session.list_tools()
91
- return [tool.name for tool in result.tools]
92
- except Exception:
93
- # Return empty list on error
94
- return []
245
+ session = await self._get_session()
246
+ result = await session.list_tools()
247
+ return [tool.name for tool in result.tools]
95
248
 
96
249
  async def close(self) -> None:
97
250
  """Close the MCP session."""
98
251
  if self._exit_stack is not None:
99
- await self._exit_stack.aclose()
252
+ old_stack = self._exit_stack
100
253
  self._exit_stack = None
101
254
  self._session = None
255
+ try:
256
+ await old_stack.aclose()
257
+ except (asyncio.CancelledError, RuntimeError):
258
+ # Cleanup errors during shutdown are expected
259
+ pass
260
+ except Exception as e:
261
+ logger.debug(f"Error during MCP session cleanup: {e}")
adcp/types/__init__.py CHANGED
@@ -1,9 +1,12 @@
1
+ from __future__ import annotations
2
+
1
3
  """Type definitions for AdCP client."""
2
4
 
3
5
  from adcp.types.core import (
4
6
  Activity,
5
7
  ActivityType,
6
8
  AgentConfig,
9
+ DebugInfo,
7
10
  Protocol,
8
11
  TaskResult,
9
12
  TaskStatus,
@@ -18,4 +21,5 @@ __all__ = [
18
21
  "WebhookMetadata",
19
22
  "Activity",
20
23
  "ActivityType",
24
+ "DebugInfo",
21
25
  ]
adcp/types/core.py CHANGED
@@ -1,9 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  """Core type definitions."""
2
4
 
3
5
  from enum import Enum
4
6
  from typing import Any, Generic, Literal, TypeVar
5
7
 
6
- from pydantic import BaseModel, Field
8
+ from pydantic import BaseModel, Field, field_validator
7
9
 
8
10
 
9
11
  class Protocol(str, Enum):
@@ -21,6 +23,68 @@ class AgentConfig(BaseModel):
21
23
  protocol: Protocol
22
24
  auth_token: str | None = None
23
25
  requires_auth: bool = False
26
+ auth_header: str = "x-adcp-auth" # Header name for authentication
27
+ auth_type: str = "token" # "token" for direct value, "bearer" for "Bearer {token}"
28
+ timeout: float = 30.0 # Request timeout in seconds
29
+ mcp_transport: str = (
30
+ "streamable_http" # "streamable_http" (default, modern) or "sse" (legacy fallback)
31
+ )
32
+ debug: bool = False # Enable debug mode to capture request/response details
33
+
34
+ @field_validator("agent_uri")
35
+ @classmethod
36
+ def validate_agent_uri(cls, v: str) -> str:
37
+ """Validate agent URI format."""
38
+ if not v:
39
+ raise ValueError("agent_uri cannot be empty")
40
+
41
+ if not v.startswith(("http://", "https://")):
42
+ raise ValueError(
43
+ f"agent_uri must start with http:// or https://, got: {v}\n"
44
+ "Example: https://agent.example.com"
45
+ )
46
+
47
+ # Remove trailing slash for consistency
48
+ return v.rstrip("/")
49
+
50
+ @field_validator("timeout")
51
+ @classmethod
52
+ def validate_timeout(cls, v: float) -> float:
53
+ """Validate timeout is reasonable."""
54
+ if v <= 0:
55
+ raise ValueError(f"timeout must be positive, got: {v}")
56
+
57
+ if v > 300: # 5 minutes
58
+ raise ValueError(
59
+ f"timeout is very large ({v}s). Consider a value under 300 seconds.\n"
60
+ "Large timeouts can cause long hangs if agent is unresponsive."
61
+ )
62
+
63
+ return v
64
+
65
+ @field_validator("mcp_transport")
66
+ @classmethod
67
+ def validate_mcp_transport(cls, v: str) -> str:
68
+ """Validate MCP transport type."""
69
+ valid_transports = ["streamable_http", "sse"]
70
+ if v not in valid_transports:
71
+ raise ValueError(
72
+ f"mcp_transport must be one of {valid_transports}, got: {v}\n"
73
+ "Use 'streamable_http' for modern agents (recommended)"
74
+ )
75
+ return v
76
+
77
+ @field_validator("auth_type")
78
+ @classmethod
79
+ def validate_auth_type(cls, v: str) -> str:
80
+ """Validate auth type."""
81
+ valid_types = ["token", "bearer"]
82
+ if v not in valid_types:
83
+ raise ValueError(
84
+ f"auth_type must be one of {valid_types}, got: {v}\n"
85
+ "Use 'bearer' for OAuth2/standard Authorization header"
86
+ )
87
+ return v
24
88
 
25
89
 
26
90
  class TaskStatus(str, Enum):
@@ -50,6 +114,14 @@ class NeedsInputInfo(BaseModel):
50
114
  field: str | None = None
51
115
 
52
116
 
117
+ class DebugInfo(BaseModel):
118
+ """Debug information for troubleshooting."""
119
+
120
+ request: dict[str, Any]
121
+ response: dict[str, Any]
122
+ duration_ms: float | None = None
123
+
124
+
53
125
  class TaskResult(BaseModel, Generic[T]):
54
126
  """Result from task execution."""
55
127
 
@@ -60,6 +132,7 @@ class TaskResult(BaseModel, Generic[T]):
60
132
  error: str | None = None
61
133
  success: bool = Field(default=True)
62
134
  metadata: dict[str, Any] | None = None
135
+ debug_info: DebugInfo | None = None
63
136
 
64
137
  class Config:
65
138
  arbitrary_types_allowed = True
@@ -78,6 +151,8 @@ class ActivityType(str, Enum):
78
151
  class Activity(BaseModel):
79
152
  """Activity event for observability."""
80
153
 
154
+ model_config = {"frozen": True}
155
+
81
156
  type: ActivityType
82
157
  operation_id: str
83
158
  agent_id: str