utcp-mcp 1.0.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.
- utcp_mcp-1.0.0/PKG-INFO +25 -0
- utcp_mcp-1.0.0/pyproject.toml +43 -0
- utcp_mcp-1.0.0/setup.cfg +4 -0
- utcp_mcp-1.0.0/src/utcp_mcp/__init__.py +13 -0
- utcp_mcp-1.0.0/src/utcp_mcp/mcp_call_template.py +54 -0
- utcp_mcp-1.0.0/src/utcp_mcp/mcp_communication_protocol.py +274 -0
- utcp_mcp-1.0.0/src/utcp_mcp.egg-info/PKG-INFO +25 -0
- utcp_mcp-1.0.0/src/utcp_mcp.egg-info/SOURCES.txt +12 -0
- utcp_mcp-1.0.0/src/utcp_mcp.egg-info/dependency_links.txt +1 -0
- utcp_mcp-1.0.0/src/utcp_mcp.egg-info/entry_points.txt +2 -0
- utcp_mcp-1.0.0/src/utcp_mcp.egg-info/requires.txt +11 -0
- utcp_mcp-1.0.0/src/utcp_mcp.egg-info/top_level.txt +1 -0
- utcp_mcp-1.0.0/tests/test_mcp_http_transport.py +195 -0
- utcp_mcp-1.0.0/tests/test_mcp_transport.py +120 -0
utcp_mcp-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal Tool Calling Protocol (UTCP) client library for Python
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: mcp>=1.12
|
|
18
|
+
Requires-Dist: utcp>=1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: coverage; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "utcp-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "UTCP Contributors" },
|
|
10
|
+
]
|
|
11
|
+
description = "Universal Tool Calling Protocol (UTCP) client library for Python"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.0",
|
|
16
|
+
"mcp>=1.12",
|
|
17
|
+
"utcp>=1.0"
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
license = "MPL-2.0"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"build",
|
|
30
|
+
"pytest",
|
|
31
|
+
"pytest-asyncio",
|
|
32
|
+
"pytest-cov",
|
|
33
|
+
"coverage",
|
|
34
|
+
"twine",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://utcp.io"
|
|
39
|
+
Source = "https://github.com/universal-tool-calling-protocol/python-utcp"
|
|
40
|
+
Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues"
|
|
41
|
+
|
|
42
|
+
[project.entry-points."utcp.plugins"]
|
|
43
|
+
mcp = "utcp_mcp:register"
|
utcp_mcp-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from utcp_mcp.mcp_communication_protocol import McpCommunicationProtocol
|
|
2
|
+
from utcp_mcp.mcp_call_template import McpCallTemplate, McpCallTemplateSerializer
|
|
3
|
+
from utcp.plugins.discovery import register_communication_protocol, register_call_template
|
|
4
|
+
|
|
5
|
+
def register():
|
|
6
|
+
register_communication_protocol("mcp", McpCommunicationProtocol())
|
|
7
|
+
register_call_template("mcp", McpCallTemplateSerializer())
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"McpCommunicationProtocol",
|
|
11
|
+
"McpCallTemplate",
|
|
12
|
+
"McpCallTemplateSerializer",
|
|
13
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import Optional, Dict, Literal, Any
|
|
4
|
+
from utcp.data.auth_implementations import OAuth2Auth
|
|
5
|
+
from utcp.data.call_template import CallTemplate
|
|
6
|
+
from utcp.interfaces.serializer import Serializer
|
|
7
|
+
from utcp.exceptions import UtcpSerializerValidationError
|
|
8
|
+
import traceback
|
|
9
|
+
|
|
10
|
+
"""Type alias for MCP server configurations.
|
|
11
|
+
|
|
12
|
+
Union type for all supported MCP server transport configurations,
|
|
13
|
+
including both stdio and HTTP-based servers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
class McpConfig(BaseModel):
|
|
17
|
+
"""Configuration container for multiple MCP servers.
|
|
18
|
+
|
|
19
|
+
Holds a collection of named MCP server configurations, allowing
|
|
20
|
+
a single MCP provider to manage multiple server connections.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
mcpServers: Dictionary mapping server names to their configurations.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
mcpServers: Dict[str, Dict[str, Any]]
|
|
27
|
+
|
|
28
|
+
class McpCallTemplate(CallTemplate):
|
|
29
|
+
"""Provider configuration for Model Context Protocol (MCP) tools.
|
|
30
|
+
|
|
31
|
+
Enables communication with MCP servers that provide structured tool
|
|
32
|
+
interfaces. Supports both stdio (local process) and HTTP (remote)
|
|
33
|
+
transport methods.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
call_template_type: Always "mcp" for MCP providers.
|
|
37
|
+
config: Configuration object containing MCP server definitions.
|
|
38
|
+
This follows the same format as the official MCP server configuration.
|
|
39
|
+
auth: Optional OAuth2 authentication for HTTP-based MCP servers.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
call_template_type: Literal["mcp"] = "mcp"
|
|
43
|
+
config: McpConfig
|
|
44
|
+
auth: Optional[OAuth2Auth] = None
|
|
45
|
+
|
|
46
|
+
class McpCallTemplateSerializer(Serializer[McpCallTemplate]):
|
|
47
|
+
def to_dict(self, obj: McpCallTemplate) -> dict:
|
|
48
|
+
return obj.model_dump()
|
|
49
|
+
|
|
50
|
+
def validate_dict(self, obj: dict) -> McpCallTemplate:
|
|
51
|
+
try:
|
|
52
|
+
return McpCallTemplate.model_validate(obj)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise UtcpSerializerValidationError("Invalid McpCallTemplate: " + traceback.format_exc()) from e
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from typing import Any, Dict, Optional, AsyncGenerator, TYPE_CHECKING
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from mcp import ClientSession, StdioServerParameters
|
|
5
|
+
from mcp.client.stdio import stdio_client
|
|
6
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
7
|
+
from utcp.data.utcp_manual import UtcpManual
|
|
8
|
+
from utcp.data.call_template import CallTemplate
|
|
9
|
+
from utcp.data.tool import Tool
|
|
10
|
+
from utcp.data.auth_implementations import OAuth2Auth
|
|
11
|
+
from utcp.interfaces.communication_protocol import CommunicationProtocol
|
|
12
|
+
from utcp.data.register_manual_response import RegisterManualResult
|
|
13
|
+
import aiohttp
|
|
14
|
+
from aiohttp import BasicAuth as AiohttpBasicAuth
|
|
15
|
+
from utcp_mcp.mcp_call_template import McpCallTemplate
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from utcp.utcp_client import UtcpClient
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
class McpCommunicationProtocol(CommunicationProtocol):
|
|
23
|
+
"""MCP transport implementation that connects to MCP servers via stdio or HTTP.
|
|
24
|
+
|
|
25
|
+
This implementation uses a session-per-operation approach where each operation
|
|
26
|
+
(register, call_tool) opens a fresh session, performs the operation, and closes.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
|
|
31
|
+
|
|
32
|
+
async def _list_tools_with_session(self, server_config: Dict[str, Any], auth: Optional[OAuth2Auth] = None):
|
|
33
|
+
# Create client streams based on transport type
|
|
34
|
+
if "command" in server_config and "args" in server_config:
|
|
35
|
+
params = StdioServerParameters(**server_config)
|
|
36
|
+
async with stdio_client(params) as (read, write):
|
|
37
|
+
async with ClientSession(read, write) as session:
|
|
38
|
+
await session.initialize()
|
|
39
|
+
tools_response = await session.list_tools()
|
|
40
|
+
return tools_response.tools
|
|
41
|
+
elif "url" in server_config:
|
|
42
|
+
# Get authentication token if OAuth2 is configured
|
|
43
|
+
auth_header = None
|
|
44
|
+
if auth and isinstance(auth, OAuth2Auth):
|
|
45
|
+
token = await self._handle_oauth2(auth)
|
|
46
|
+
auth_header = {"Authorization": f"Bearer {token}"}
|
|
47
|
+
|
|
48
|
+
async with streamablehttp_client(server_config["url"], auth=auth_header) as (read, write, _):
|
|
49
|
+
async with ClientSession(read, write) as session:
|
|
50
|
+
await session.initialize()
|
|
51
|
+
tools_response = await session.list_tools()
|
|
52
|
+
return tools_response.tools
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError(f"Unsupported MCP transport: {json.dumps(server_config)}")
|
|
55
|
+
|
|
56
|
+
async def _call_tool_with_session(self, server_config: Dict[str, Any], tool_name: str, inputs: Dict[str, Any], auth: Optional[OAuth2Auth] = None):
|
|
57
|
+
if "command" in server_config and "args" in server_config:
|
|
58
|
+
params = StdioServerParameters(**server_config)
|
|
59
|
+
async with stdio_client(params) as (read, write):
|
|
60
|
+
async with ClientSession(read, write) as session:
|
|
61
|
+
await session.initialize()
|
|
62
|
+
result = await session.call_tool(tool_name, arguments=inputs)
|
|
63
|
+
return result
|
|
64
|
+
elif "url" in server_config:
|
|
65
|
+
# Get authentication token if OAuth2 is configured
|
|
66
|
+
auth_header = None
|
|
67
|
+
if auth and isinstance(auth, OAuth2Auth):
|
|
68
|
+
token = await self._handle_oauth2(auth)
|
|
69
|
+
auth_header = {"Authorization": f"Bearer {token}"}
|
|
70
|
+
|
|
71
|
+
async with streamablehttp_client(
|
|
72
|
+
url=server_config["url"],
|
|
73
|
+
headers=server_config.get("headers", None),
|
|
74
|
+
timeout=server_config.get("timeout", 30),
|
|
75
|
+
sse_read_timeout=server_config.get("sse_read_timeout", 60 * 5),
|
|
76
|
+
terminate_on_close=server_config.get("terminate_on_close", True),
|
|
77
|
+
auth=auth_header
|
|
78
|
+
) as (read, write, _):
|
|
79
|
+
async with ClientSession(read, write) as session:
|
|
80
|
+
await session.initialize()
|
|
81
|
+
result = await session.call_tool(tool_name, arguments=inputs)
|
|
82
|
+
return result
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError(f"Unsupported MCP transport: {json.dumps(server_config)}")
|
|
85
|
+
|
|
86
|
+
async def register_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> RegisterManualResult:
|
|
87
|
+
if not isinstance(manual_call_template, McpCallTemplate):
|
|
88
|
+
raise ValueError("manual_call_template must be a McpCallTemplate")
|
|
89
|
+
all_tools = []
|
|
90
|
+
errors = []
|
|
91
|
+
if manual_call_template.config and manual_call_template.config.mcpServers:
|
|
92
|
+
for server_name, server_config in manual_call_template.config.mcpServers.items():
|
|
93
|
+
try:
|
|
94
|
+
logger.info(f"Discovering tools for server '{server_name}' via {server_config}")
|
|
95
|
+
mcp_tools = await self._list_tools_with_session(server_config, auth=manual_call_template.auth)
|
|
96
|
+
logger.info(f"Discovered {len(mcp_tools)} tools for server '{server_name}'")
|
|
97
|
+
for mcp_tool in mcp_tools:
|
|
98
|
+
# Convert mcp.Tool to utcp.data.tool.Tool
|
|
99
|
+
utcp_tool = Tool(
|
|
100
|
+
name=mcp_tool.name,
|
|
101
|
+
description=mcp_tool.description,
|
|
102
|
+
input_schema=mcp_tool.inputSchema,
|
|
103
|
+
output_schema=mcp_tool.outputSchema,
|
|
104
|
+
tool_call_template=manual_call_template
|
|
105
|
+
)
|
|
106
|
+
all_tools.append(utcp_tool)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Failed to discover tools for server '{server_name}': {e}")
|
|
109
|
+
errors.append(f"Failed to discover tools for server '{server_name}': {e}")
|
|
110
|
+
return RegisterManualResult(
|
|
111
|
+
manual_call_template=manual_call_template,
|
|
112
|
+
manual=UtcpManual(
|
|
113
|
+
tools=all_tools
|
|
114
|
+
),
|
|
115
|
+
success=len(errors) == 0,
|
|
116
|
+
errors=errors
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def call_tool(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
|
|
120
|
+
if not isinstance(tool_call_template, McpCallTemplate):
|
|
121
|
+
raise ValueError("tool_call_template must be a McpCallTemplate")
|
|
122
|
+
if not tool_call_template.config or not tool_call_template.config.mcpServers:
|
|
123
|
+
raise ValueError(f"No server configuration found for tool '{tool_name}'")
|
|
124
|
+
|
|
125
|
+
# Try each server until we find one that has the tool
|
|
126
|
+
for server_name, server_config in tool_call_template.config.mcpServers.items():
|
|
127
|
+
try:
|
|
128
|
+
logger.info(f"Attempting to call tool '{tool_name}' on server '{server_name}'")
|
|
129
|
+
|
|
130
|
+
# First check if this server has the tool
|
|
131
|
+
tools = await self._list_tools_with_session(server_config, auth=tool_call_template.auth)
|
|
132
|
+
tool_names = [tool.name for tool in tools]
|
|
133
|
+
|
|
134
|
+
if tool_name not in tool_names:
|
|
135
|
+
logger.info(f"Tool '{tool_name}' not found in server '{server_name}'")
|
|
136
|
+
continue # Try next server
|
|
137
|
+
|
|
138
|
+
# Call the tool
|
|
139
|
+
result = await self._call_tool_with_session(server_config, tool_name, tool_args, auth=tool_call_template.auth)
|
|
140
|
+
|
|
141
|
+
# Process the result
|
|
142
|
+
return self._process_tool_result(result, tool_name)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Error calling tool '{tool_name}' on server '{server_name}': {e}")
|
|
145
|
+
continue # Try next server
|
|
146
|
+
|
|
147
|
+
raise ValueError(f"Tool '{tool_name}' not found in any configured server")
|
|
148
|
+
|
|
149
|
+
async def call_tool_streaming(self, caller: 'UtcpClient', tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
|
|
150
|
+
yield self.call_tool(caller, tool_name, tool_args, tool_call_template)
|
|
151
|
+
|
|
152
|
+
def _process_tool_result(self, result, tool_name: str) -> Any:
|
|
153
|
+
logger.info(f"Processing tool result for '{tool_name}', type: {type(result)}")
|
|
154
|
+
|
|
155
|
+
# Check for structured output first
|
|
156
|
+
if hasattr(result, 'structured_output'):
|
|
157
|
+
logger.info(f"Found structured_output: {result.structured_output}")
|
|
158
|
+
return result.structured_output
|
|
159
|
+
|
|
160
|
+
# Process content if available
|
|
161
|
+
if hasattr(result, 'content'):
|
|
162
|
+
content = result.content
|
|
163
|
+
logger.info(f"Content type: {type(content)}")
|
|
164
|
+
|
|
165
|
+
# Handle list content
|
|
166
|
+
if isinstance(content, list):
|
|
167
|
+
logger.info(f"Content is a list with {len(content)} items")
|
|
168
|
+
|
|
169
|
+
if not content:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
# For single item lists, extract the item
|
|
173
|
+
if len(content) == 1:
|
|
174
|
+
item = content[0]
|
|
175
|
+
if hasattr(item, 'text'):
|
|
176
|
+
return self._parse_text_content(item.text)
|
|
177
|
+
return item
|
|
178
|
+
|
|
179
|
+
# For multiple items, process all
|
|
180
|
+
result_list = []
|
|
181
|
+
for item in content:
|
|
182
|
+
if hasattr(item, 'text'):
|
|
183
|
+
result_list.append(self._parse_text_content(item.text))
|
|
184
|
+
else:
|
|
185
|
+
result_list.append(item)
|
|
186
|
+
return result_list
|
|
187
|
+
|
|
188
|
+
# Handle single TextContent
|
|
189
|
+
if hasattr(content, 'text'):
|
|
190
|
+
return self._parse_text_content(content.text)
|
|
191
|
+
|
|
192
|
+
# Handle other content types
|
|
193
|
+
if hasattr(content, 'json'):
|
|
194
|
+
return content.json
|
|
195
|
+
|
|
196
|
+
return content
|
|
197
|
+
|
|
198
|
+
# Fallback to result attribute
|
|
199
|
+
if hasattr(result, 'result'):
|
|
200
|
+
return result.result
|
|
201
|
+
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
def _parse_text_content(self, text: str) -> Any:
|
|
205
|
+
"""Parse text content, attempting JSON, numbers, or returning as string."""
|
|
206
|
+
if not text:
|
|
207
|
+
return text
|
|
208
|
+
|
|
209
|
+
# Try JSON parsing
|
|
210
|
+
try:
|
|
211
|
+
if (text.strip().startswith('{') and text.strip().endswith('}')) or \
|
|
212
|
+
(text.strip().startswith('[') and text.strip().endswith(']')):
|
|
213
|
+
return json.loads(text)
|
|
214
|
+
except json.JSONDecodeError:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Try number parsing
|
|
218
|
+
try:
|
|
219
|
+
if text.isdigit() or (text.startswith('-') and text[1:].isdigit()):
|
|
220
|
+
return int(text)
|
|
221
|
+
return float(text)
|
|
222
|
+
except ValueError:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
# Return as string
|
|
226
|
+
return text
|
|
227
|
+
|
|
228
|
+
async def deregister_manual(self, caller: 'UtcpClient', manual_call_template: CallTemplate) -> None:
|
|
229
|
+
"""Deregister an MCP manual. This is a no-op in session-per-operation mode."""
|
|
230
|
+
logger.info(f"Deregistering manual '{manual_call_template.name}' (no-op in session-per-operation mode)")
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str:
|
|
234
|
+
"""Handles OAuth2 client credentials flow, trying both body and auth header methods."""
|
|
235
|
+
client_id = auth_details.client_id
|
|
236
|
+
|
|
237
|
+
# Return cached token if available
|
|
238
|
+
if client_id in self._oauth_tokens:
|
|
239
|
+
return self._oauth_tokens[client_id]["access_token"]
|
|
240
|
+
|
|
241
|
+
async with aiohttp.ClientSession() as session:
|
|
242
|
+
# Method 1: Send credentials in the request body
|
|
243
|
+
try:
|
|
244
|
+
logger.info(f"Attempting OAuth2 token fetch for '{client_id}' with credentials in body.")
|
|
245
|
+
body_data = {
|
|
246
|
+
'grant_type': 'client_credentials',
|
|
247
|
+
'client_id': client_id,
|
|
248
|
+
'client_secret': auth_details.client_secret,
|
|
249
|
+
'scope': auth_details.scope
|
|
250
|
+
}
|
|
251
|
+
async with session.post(auth_details.token_url, data=body_data) as response:
|
|
252
|
+
response.raise_for_status()
|
|
253
|
+
token_response = await response.json()
|
|
254
|
+
self._oauth_tokens[client_id] = token_response
|
|
255
|
+
return token_response["access_token"]
|
|
256
|
+
except aiohttp.ClientError as e:
|
|
257
|
+
logger.error(f"OAuth2 with credentials in body failed: {e}. Trying Basic Auth header.")
|
|
258
|
+
|
|
259
|
+
# Method 2: Send credentials as Basic Auth header
|
|
260
|
+
try:
|
|
261
|
+
logger.info(f"Attempting OAuth2 token fetch for '{client_id}' with Basic Auth header.")
|
|
262
|
+
header_auth = AiohttpBasicAuth(client_id, auth_details.client_secret)
|
|
263
|
+
header_data = {
|
|
264
|
+
'grant_type': 'client_credentials',
|
|
265
|
+
'scope': auth_details.scope
|
|
266
|
+
}
|
|
267
|
+
async with session.post(auth_details.token_url, data=header_data, auth=header_auth) as response:
|
|
268
|
+
response.raise_for_status()
|
|
269
|
+
token_response = await response.json()
|
|
270
|
+
self._oauth_tokens[client_id] = token_response
|
|
271
|
+
return token_response["access_token"]
|
|
272
|
+
except aiohttp.ClientError as e:
|
|
273
|
+
logger.error(f"OAuth2 with Basic Auth header also failed: {e}")
|
|
274
|
+
raise e
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utcp-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Universal Tool Calling Protocol (UTCP) client library for Python
|
|
5
|
+
Author: UTCP Contributors
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
|
+
Project-URL: Homepage, https://utcp.io
|
|
8
|
+
Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
|
|
9
|
+
Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: mcp>=1.12
|
|
18
|
+
Requires-Dist: utcp>=1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: coverage; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/utcp_mcp/__init__.py
|
|
3
|
+
src/utcp_mcp/mcp_call_template.py
|
|
4
|
+
src/utcp_mcp/mcp_communication_protocol.py
|
|
5
|
+
src/utcp_mcp.egg-info/PKG-INFO
|
|
6
|
+
src/utcp_mcp.egg-info/SOURCES.txt
|
|
7
|
+
src/utcp_mcp.egg-info/dependency_links.txt
|
|
8
|
+
src/utcp_mcp.egg-info/entry_points.txt
|
|
9
|
+
src/utcp_mcp.egg-info/requires.txt
|
|
10
|
+
src/utcp_mcp.egg-info/top_level.txt
|
|
11
|
+
tests/test_mcp_http_transport.py
|
|
12
|
+
tests/test_mcp_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utcp_mcp
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the MCP transport interface with HTTP transport.
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
import pytest
|
|
6
|
+
import pytest_asyncio
|
|
7
|
+
import asyncio
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from utcp_mcp.mcp_call_template import McpCallTemplate, McpConfig
|
|
15
|
+
from utcp_mcp.mcp_communication_protocol import McpCommunicationProtocol
|
|
16
|
+
|
|
17
|
+
HTTP_SERVER_NAME = "mock_http_server"
|
|
18
|
+
HTTP_SERVER_PORT = 8000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest_asyncio.fixture
|
|
22
|
+
async def http_server_process() -> subprocess.Popen:
|
|
23
|
+
"""Start the HTTP MCP server as a separate process."""
|
|
24
|
+
server_path = os.path.join(
|
|
25
|
+
os.path.dirname(__file__), "mock_http_mcp_server.py"
|
|
26
|
+
)
|
|
27
|
+
process = subprocess.Popen(
|
|
28
|
+
[sys.executable, server_path],
|
|
29
|
+
stdout=subprocess.PIPE,
|
|
30
|
+
stderr=subprocess.PIPE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Wait for the server to be ready by checking if the port is accessible and server logs
|
|
34
|
+
server_ready = False
|
|
35
|
+
for _ in range(30): # Wait up to 30 seconds
|
|
36
|
+
try:
|
|
37
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
38
|
+
s.settimeout(1)
|
|
39
|
+
result = s.connect_ex(("127.0.0.1", HTTP_SERVER_PORT))
|
|
40
|
+
if result == 0:
|
|
41
|
+
# Also check if we can see the server startup message
|
|
42
|
+
server_ready = True
|
|
43
|
+
break
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
await asyncio.sleep(1)
|
|
47
|
+
|
|
48
|
+
if not server_ready:
|
|
49
|
+
# Server didn't start in time
|
|
50
|
+
process.terminate()
|
|
51
|
+
stdout, stderr = process.communicate()
|
|
52
|
+
raise RuntimeError(f"HTTP server failed to start. stdout: {stdout.decode()}, stderr: {stderr.decode()}")
|
|
53
|
+
|
|
54
|
+
# Give the server a bit more time to fully initialize
|
|
55
|
+
await asyncio.sleep(2)
|
|
56
|
+
|
|
57
|
+
yield process
|
|
58
|
+
|
|
59
|
+
# Clean up the process
|
|
60
|
+
process.terminate()
|
|
61
|
+
try:
|
|
62
|
+
process.wait(timeout=5)
|
|
63
|
+
except subprocess.TimeoutExpired:
|
|
64
|
+
process.kill()
|
|
65
|
+
process.wait()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest_asyncio.fixture
|
|
69
|
+
def http_mcp_provider() -> McpCallTemplate:
|
|
70
|
+
"""Provides an McpCallTemplate configured to connect to the mock HTTP server."""
|
|
71
|
+
server_config = {
|
|
72
|
+
"url": f"http://127.0.0.1:{HTTP_SERVER_PORT}/mcp",
|
|
73
|
+
"transport": "http"
|
|
74
|
+
}
|
|
75
|
+
return McpCallTemplate(
|
|
76
|
+
name="mock_http_provider",
|
|
77
|
+
call_template_type="mcp",
|
|
78
|
+
config=McpConfig(mcpServers={HTTP_SERVER_NAME: server_config})
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest_asyncio.fixture
|
|
83
|
+
async def transport() -> McpCommunicationProtocol:
|
|
84
|
+
"""Provides a clean McpCommunicationProtocol instance."""
|
|
85
|
+
t = McpCommunicationProtocol()
|
|
86
|
+
yield t
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_http_register_manual_discovers_tools(
|
|
91
|
+
transport: McpCommunicationProtocol,
|
|
92
|
+
http_mcp_provider: McpCallTemplate,
|
|
93
|
+
http_server_process: subprocess.Popen
|
|
94
|
+
):
|
|
95
|
+
"""Test that registering an HTTP MCP manual discovers the correct tools."""
|
|
96
|
+
register_result = await transport.register_manual(None, http_mcp_provider)
|
|
97
|
+
assert register_result.success
|
|
98
|
+
assert len(register_result.manual.tools) == 4
|
|
99
|
+
|
|
100
|
+
# Find the echo tool
|
|
101
|
+
echo_tool = next((tool for tool in register_result.manual.tools if tool.name == "echo"), None)
|
|
102
|
+
assert echo_tool is not None
|
|
103
|
+
assert "echoes back its input" in echo_tool.description
|
|
104
|
+
|
|
105
|
+
# Check for other tools
|
|
106
|
+
tool_names = [tool.name for tool in register_result.manual.tools]
|
|
107
|
+
assert "greet" in tool_names
|
|
108
|
+
assert "list_items" in tool_names
|
|
109
|
+
assert "add_numbers" in tool_names
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_http_structured_output(
|
|
114
|
+
transport: McpCommunicationProtocol,
|
|
115
|
+
http_mcp_provider: McpCallTemplate,
|
|
116
|
+
http_server_process: subprocess.Popen
|
|
117
|
+
):
|
|
118
|
+
"""Test that HTTP MCP tools with structured output work correctly."""
|
|
119
|
+
# Register the provider
|
|
120
|
+
await transport.register_manual(None, http_mcp_provider)
|
|
121
|
+
|
|
122
|
+
# Call the echo tool and verify the result
|
|
123
|
+
result = await transport.call_tool(None, "echo", {"message": "http_test"}, http_mcp_provider)
|
|
124
|
+
assert result == {"reply": "you said: http_test"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_http_unstructured_output(
|
|
129
|
+
transport: McpCommunicationProtocol,
|
|
130
|
+
http_mcp_provider: McpCallTemplate,
|
|
131
|
+
http_server_process: subprocess.Popen
|
|
132
|
+
):
|
|
133
|
+
"""Test that HTTP MCP tools with unstructured output types work correctly."""
|
|
134
|
+
# Register the provider
|
|
135
|
+
await transport.register_manual(None, http_mcp_provider)
|
|
136
|
+
|
|
137
|
+
# Call the greet tool and verify the result
|
|
138
|
+
result = await transport.call_tool(None, "greet", {"name": "Alice"}, http_mcp_provider)
|
|
139
|
+
assert result == "Hello, Alice!"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_http_list_output(
|
|
144
|
+
transport: McpCommunicationProtocol,
|
|
145
|
+
http_mcp_provider: McpCallTemplate,
|
|
146
|
+
http_server_process: subprocess.Popen
|
|
147
|
+
):
|
|
148
|
+
"""Test that HTTP MCP tools returning lists work correctly."""
|
|
149
|
+
# Register the provider
|
|
150
|
+
await transport.register_manual(None, http_mcp_provider)
|
|
151
|
+
|
|
152
|
+
# Call the list_items tool and verify the result
|
|
153
|
+
result = await transport.call_tool(None, "list_items", {"count": 3}, http_mcp_provider)
|
|
154
|
+
|
|
155
|
+
assert isinstance(result, list)
|
|
156
|
+
assert len(result) == 3
|
|
157
|
+
assert result[0] == "item_0"
|
|
158
|
+
assert result[1] == "item_1"
|
|
159
|
+
assert result[2] == "item_2"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.asyncio
|
|
163
|
+
async def test_http_numeric_output(
|
|
164
|
+
transport: McpCommunicationProtocol,
|
|
165
|
+
http_mcp_provider: McpCallTemplate,
|
|
166
|
+
http_server_process: subprocess.Popen
|
|
167
|
+
):
|
|
168
|
+
"""Test that HTTP MCP tools returning numeric values work correctly."""
|
|
169
|
+
# Register the provider
|
|
170
|
+
await transport.register_manual(None, http_mcp_provider)
|
|
171
|
+
|
|
172
|
+
# Call the add_numbers tool and verify the result
|
|
173
|
+
result = await transport.call_tool(None, "add_numbers", {"a": 5, "b": 7}, http_mcp_provider)
|
|
174
|
+
|
|
175
|
+
assert result == 12
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_http_deregister_manual(
|
|
180
|
+
transport: McpCommunicationProtocol,
|
|
181
|
+
http_mcp_provider: McpCallTemplate,
|
|
182
|
+
http_server_process: subprocess.Popen
|
|
183
|
+
):
|
|
184
|
+
"""Test that deregistering an HTTP MCP manual works (no-op in session-per-operation mode)."""
|
|
185
|
+
# Register a manual
|
|
186
|
+
register_result = await transport.register_manual(None, http_mcp_provider)
|
|
187
|
+
assert register_result.success
|
|
188
|
+
assert len(register_result.manual.tools) == 4
|
|
189
|
+
|
|
190
|
+
# Deregister it (this is a no-op in session-per-operation mode)
|
|
191
|
+
await transport.deregister_manual(None, http_mcp_provider)
|
|
192
|
+
|
|
193
|
+
# Should still be able to call tools since we create fresh sessions
|
|
194
|
+
result = await transport.call_tool(None, "echo", {"message": "test"}, http_mcp_provider)
|
|
195
|
+
assert result == {"reply": "you said: test"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import pytest
|
|
4
|
+
import pytest_asyncio
|
|
5
|
+
|
|
6
|
+
from utcp_mcp.mcp_communication_protocol import McpCommunicationProtocol
|
|
7
|
+
from utcp_mcp.mcp_call_template import McpCallTemplate, McpConfig
|
|
8
|
+
|
|
9
|
+
SERVER_NAME = "mock_stdio_server"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest_asyncio.fixture
|
|
13
|
+
def mcp_manual() -> McpCallTemplate:
|
|
14
|
+
"""Provides an McpCallTemplate configured to run the mock stdio server."""
|
|
15
|
+
server_path = os.path.join(os.path.dirname(__file__), "mock_mcp_server.py")
|
|
16
|
+
server_config = {
|
|
17
|
+
"command": sys.executable,
|
|
18
|
+
"args": [server_path],
|
|
19
|
+
}
|
|
20
|
+
return McpCallTemplate(
|
|
21
|
+
name="mock_mcp_manual",
|
|
22
|
+
call_template_type="mcp",
|
|
23
|
+
config=McpConfig(mcpServers={SERVER_NAME: server_config})
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest_asyncio.fixture
|
|
28
|
+
async def transport() -> McpCommunicationProtocol:
|
|
29
|
+
"""Provides a clean McpCommunicationProtocol instance."""
|
|
30
|
+
t = McpCommunicationProtocol()
|
|
31
|
+
yield t
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_register_manual_discovers_tools(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
36
|
+
"""Verify that registering a manual discovers the correct tools."""
|
|
37
|
+
register_result = await transport.register_manual(None, mcp_manual)
|
|
38
|
+
assert register_result.success
|
|
39
|
+
assert len(register_result.manual.tools) == 4
|
|
40
|
+
|
|
41
|
+
# Find the echo tool
|
|
42
|
+
echo_tool = next((tool for tool in register_result.manual.tools if tool.name == "echo"), None)
|
|
43
|
+
assert echo_tool is not None
|
|
44
|
+
assert "echoes back its input" in echo_tool.description
|
|
45
|
+
|
|
46
|
+
# Check for other tools
|
|
47
|
+
tool_names = [tool.name for tool in register_result.manual.tools]
|
|
48
|
+
assert "greet" in tool_names
|
|
49
|
+
assert "list_items" in tool_names
|
|
50
|
+
assert "add_numbers" in tool_names
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_call_tool_succeeds(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
55
|
+
"""Verify a successful tool call after registration."""
|
|
56
|
+
await transport.register_manual(None, mcp_manual)
|
|
57
|
+
|
|
58
|
+
result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual)
|
|
59
|
+
|
|
60
|
+
assert result == {"reply": "you said: test"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_call_tool_works_without_register(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
65
|
+
"""Verify that calling a tool works without prior registration in session-per-operation mode."""
|
|
66
|
+
result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual)
|
|
67
|
+
assert result == {"reply": "you said: test"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_structured_output_tool(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
72
|
+
"""Test that tools with structured output (TypedDict) work correctly."""
|
|
73
|
+
await transport.register_manual(None, mcp_manual)
|
|
74
|
+
|
|
75
|
+
result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual)
|
|
76
|
+
assert result == {"reply": "you said: test"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_unstructured_string_output(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
81
|
+
"""Test that tools returning plain strings work correctly."""
|
|
82
|
+
await transport.register_manual(None, mcp_manual)
|
|
83
|
+
|
|
84
|
+
result = await transport.call_tool(None, "greet", {"name": "Alice"}, mcp_manual)
|
|
85
|
+
assert result == "Hello, Alice!"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_list_output(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
90
|
+
"""Test that tools returning lists work correctly."""
|
|
91
|
+
await transport.register_manual(None, mcp_manual)
|
|
92
|
+
|
|
93
|
+
result = await transport.call_tool(None, "list_items", {"count": 3}, mcp_manual)
|
|
94
|
+
|
|
95
|
+
assert isinstance(result, list)
|
|
96
|
+
assert len(result) == 3
|
|
97
|
+
assert result == ["item_0", "item_1", "item_2"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_numeric_output(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
102
|
+
"""Test that tools returning numeric values work correctly."""
|
|
103
|
+
await transport.register_manual(None, mcp_manual)
|
|
104
|
+
|
|
105
|
+
result = await transport.call_tool(None, "add_numbers", {"a": 5, "b": 7}, mcp_manual)
|
|
106
|
+
|
|
107
|
+
assert result == 12
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_deregister_manual(transport: McpCommunicationProtocol, mcp_manual: McpCallTemplate):
|
|
112
|
+
"""Verify that deregistering a manual works (no-op in session-per-operation mode)."""
|
|
113
|
+
register_result = await transport.register_manual(None, mcp_manual)
|
|
114
|
+
assert register_result.success
|
|
115
|
+
assert len(register_result.manual.tools) == 4
|
|
116
|
+
|
|
117
|
+
await transport.deregister_manual(None, mcp_manual)
|
|
118
|
+
|
|
119
|
+
result = await transport.call_tool(None, "echo", {"message": "test"}, mcp_manual)
|
|
120
|
+
assert result == {"reply": "you said: test"}
|