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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [utcp.plugins]
2
+ mcp = utcp_mcp:register
@@ -0,0 +1,11 @@
1
+ pydantic>=2.0
2
+ mcp>=1.12
3
+ utcp>=1.0
4
+
5
+ [dev]
6
+ build
7
+ pytest
8
+ pytest-asyncio
9
+ pytest-cov
10
+ coverage
11
+ twine
@@ -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"}