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/__init__.py +80 -2
- adcp/__main__.py +284 -0
- adcp/client.py +292 -107
- adcp/config.py +82 -0
- adcp/exceptions.py +121 -0
- adcp/protocols/__init__.py +2 -0
- adcp/protocols/a2a.py +201 -87
- adcp/protocols/base.py +11 -0
- adcp/protocols/mcp.py +185 -25
- adcp/types/__init__.py +4 -0
- adcp/types/core.py +76 -1
- adcp/types/generated.py +615 -0
- adcp/types/tasks.py +281 -0
- adcp/utils/__init__.py +2 -0
- adcp/utils/operation_id.py +2 -0
- {adcp-0.1.2.dist-info → adcp-1.0.1.dist-info}/METADATA +184 -8
- adcp-1.0.1.dist-info/RECORD +21 -0
- adcp-1.0.1.dist-info/entry_points.txt +2 -0
- adcp-0.1.2.dist-info/RECORD +0 -15
- {adcp-0.1.2.dist-info → adcp-1.0.1.dist-info}/WHEEL +0 -0
- {adcp-0.1.2.dist-info → adcp-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {adcp-0.1.2.dist-info → adcp-1.0.1.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
|
8
|
-
from mcp.client.sse import sse_client
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
102
|
+
# Initialize the session
|
|
103
|
+
await self._session.initialize()
|
|
55
104
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|