mcpscore 0.3.0__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.
mcpscore/mcp_client.py ADDED
@@ -0,0 +1,408 @@
1
+ from contextlib import AsyncExitStack
2
+ import logging
3
+ import sys
4
+ import time
5
+
6
+ import httpx
7
+ from mcp import (
8
+ ClientSession,
9
+ InitializeResult,
10
+ ListPromptsResult,
11
+ ListResourcesResult,
12
+ ListToolsResult,
13
+ StdioServerParameters,
14
+ )
15
+ from mcp.client.sse import sse_client
16
+ from mcp.client.stdio import stdio_client
17
+ from mcp.client.streamable_http import streamable_http_client
18
+ from mcp.types import Prompt, Resource, Tool
19
+
20
+ from .enums import MCPTransportType
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ ERROR_NO_ACTIVE_SESSION = "No active session, connect to the MCP server first!"
25
+
26
+
27
+ class MCPClient:
28
+ """Client for connecting to and communicating with MCP (Model Context Protocol) servers.
29
+
30
+ This class provides a high-level interface for:
31
+ - Establishing connections to MCP servers via various transport methods
32
+ - Initializing server sessions
33
+ - Listing available tools and resources
34
+ - Managing connection lifecycle and cleanup
35
+
36
+ Currently supports stdio transport for local server processes.
37
+ """
38
+
39
+ def __init__(self, timeout: int | None = None) -> None:
40
+ """Initialize a new MCP client instance.
41
+
42
+ Args:
43
+ timeout: Connection timeout in seconds (None for no timeout)
44
+
45
+ Sets up the client with an empty session and async exit stack for resource management.
46
+
47
+ """
48
+ super().__init__()
49
+ self.session: ClientSession | None = None
50
+ self.exit_stack: AsyncExitStack = AsyncExitStack()
51
+ self.timeout: int | None = timeout
52
+
53
+ # Transport metadata (populated after connection)
54
+ self.transport_type: MCPTransportType | None = None
55
+ self.url: str | None = None
56
+ self.connection_time_ms: int | None = None
57
+
58
+ async def detect_and_connect(self, server_path_or_url: str) -> tuple[bool, MCPTransportType | None]:
59
+ """Automatically detect transport type and connect to MCP server.
60
+
61
+ Attempts to connect using Streamable HTTP first, then falls back to SSE.
62
+ For local files (.py, .js), uses stdio transport.
63
+
64
+ Args:
65
+ server_path_or_url: Path to server script or URL
66
+
67
+ Returns:
68
+ Tuple of (success: bool, transport: MCPTransportType | None)
69
+
70
+ """
71
+ # Check if it's a local file path
72
+ if server_path_or_url.endswith((".py", ".js")):
73
+ success = await self.connect_to_server(MCPTransportType.STDIO, server_path_or_url)
74
+ return (success, MCPTransportType.STDIO if success else None)
75
+
76
+ # Check if it's a URL
77
+ if server_path_or_url.startswith(("http://", "https://")):
78
+ # Try Streamable HTTP first
79
+ logger.info("Attempting Streamable HTTP connection...")
80
+ if await self.connect_to_server(MCPTransportType.STREAMABLE_HTTP, server_path_or_url):
81
+ return (True, MCPTransportType.STREAMABLE_HTTP)
82
+
83
+ # Fall back to SSE
84
+ logger.info("Streamable HTTP failed, trying SSE...")
85
+ if await self.connect_to_server(MCPTransportType.SSE, server_path_or_url):
86
+ return (True, MCPTransportType.SSE)
87
+
88
+ return (False, None)
89
+
90
+ logger.error("Invalid server path or URL: %s", server_path_or_url)
91
+ return (False, None)
92
+
93
+ async def connect_to_server(self, transport: MCPTransportType, server_path: str) -> bool:
94
+ """Connect to an MCP server using the specified transport method.
95
+
96
+ Args:
97
+ transport: The transport method to use (STDIO, STREAMABLE_HTTP, SSE)
98
+ server_path: Path to the server script file (.py or .js) for STDIO,
99
+ or URL for HTTP/SSE transports
100
+
101
+ Returns:
102
+ True if a connection was successful, False otherwise
103
+
104
+ Raises:
105
+ Logs errors for unsupported transport types or invalid server paths/URLs
106
+
107
+ """
108
+ result: bool = False
109
+
110
+ match transport:
111
+ case MCPTransportType.STDIO:
112
+ result = await self._connect_with_stdio(server_path)
113
+ case MCPTransportType.STREAMABLE_HTTP:
114
+ result = await self._connect_with_streamable_http(server_path)
115
+ case MCPTransportType.SSE:
116
+ result = await self._connect_with_sse(server_path)
117
+ case _:
118
+ logger.error("This protocol is not supported: %s", transport)
119
+
120
+ return result
121
+
122
+ async def _connect_with_stdio(self, server_script_path: str) -> bool:
123
+ """Establish a stdio connection to a local MCP server process.
124
+
125
+ Args:
126
+ server_script_path: Path to the server script (.py or .js file)
127
+
128
+ Returns:
129
+ True if a connection was successful, False otherwise
130
+
131
+ Note:
132
+ Automatically detects a script type and uses an appropriate launcher.
133
+ For Python scripts, uses sys.executable to ensure compatibility.
134
+
135
+ """
136
+ is_python: bool = server_script_path.endswith(".py")
137
+ is_js: bool = server_script_path.endswith(".js")
138
+ if not (is_python or is_js):
139
+ logger.error("Server script must be a .py or .js file")
140
+ return False
141
+
142
+ # Use sys.executable for Python to ensure we use the same interpreter
143
+ command: str = sys.executable if is_python else "node"
144
+ server_params = StdioServerParameters(command=command, args=[server_script_path], env=None)
145
+
146
+ try:
147
+ start_time = time.perf_counter()
148
+ self.stdio, self.write = await self.exit_stack.enter_async_context(stdio_client(server_params))
149
+ self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
150
+ self.connection_time_ms = int((time.perf_counter() - start_time) * 1000)
151
+
152
+ # Store transport metadata
153
+ self.transport_type = MCPTransportType.STDIO
154
+ self.url = None # stdio doesn't have a URL
155
+
156
+ return True
157
+ except FileNotFoundError as e:
158
+ if is_python:
159
+ logger.exception("Python interpreter not found. Please ensure Python is installed and on PATH.")
160
+ else:
161
+ logger.exception("Node.js not found. Please ensure Node.js is installed and on PATH.")
162
+ logger.debug("Error details: %s", e)
163
+ return False
164
+ except PermissionError as e:
165
+ logger.exception("Permission denied accessing server script: %s", server_script_path)
166
+ logger.debug("Error details: %s", e)
167
+ return False
168
+ except Exception:
169
+ logger.exception("Failed to connect to MCP server")
170
+ return False
171
+
172
+ async def _connect_with_streamable_http(self, server_url: str) -> bool:
173
+ """Establish HTTP connection to MCP server using streamable HTTP transport.
174
+
175
+ Args:
176
+ server_url: Full URL to MCP server endpoint (e.g., https://server.com/mcp)
177
+
178
+ Returns:
179
+ True if connection successful, False otherwise
180
+
181
+ Note:
182
+ - Requires HTTPS URL
183
+ - Implements automatic reconnection with exponential backoff
184
+ - Enforces connection timeout (15s) and total timeout (60s)
185
+ - Handles common HTTP errors (404, 500, connection refused, timeout)
186
+
187
+ """
188
+ if not server_url.startswith(("http://", "https://")):
189
+ logger.error("Invalid URL format. Must start with http:// or https://")
190
+ return False
191
+
192
+ try:
193
+ # Configure HTTP client with timeouts and retries
194
+ client = httpx.AsyncClient(
195
+ timeout=httpx.Timeout(
196
+ connect=15.0, # Connection timeout: 15 seconds
197
+ read=60.0, # Read timeout: 60 seconds
198
+ write=30.0, # Write timeout: 30 seconds
199
+ pool=5.0, # Pool timeout: 5 seconds
200
+ ),
201
+ follow_redirects=True,
202
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
203
+ )
204
+
205
+ # Establish connection using MCP SDK's streamable_http_client
206
+ start_time = time.perf_counter()
207
+ read_stream, write_stream, _session_id_callback = await self.exit_stack.enter_async_context(
208
+ streamable_http_client(server_url, http_client=client)
209
+ )
210
+
211
+ self.session = await self.exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
212
+ self.connection_time_ms = int((time.perf_counter() - start_time) * 1000)
213
+
214
+ # Store transport metadata
215
+ self.transport_type = MCPTransportType.STREAMABLE_HTTP
216
+ self.url = server_url
217
+
218
+ logger.info("Successfully connected to MCP server via Streamable HTTP: %s", server_url)
219
+ return True
220
+
221
+ except httpx.ConnectError as e:
222
+ logger.exception("Connection refused or server unreachable: %s", server_url)
223
+ logger.debug("Error details: %s", e)
224
+ return False
225
+ except httpx.TimeoutException as e:
226
+ logger.exception("Connection timeout for server: %s", server_url)
227
+ logger.debug("Error details: %s", e)
228
+ return False
229
+ except httpx.HTTPStatusError as e:
230
+ logger.exception("HTTP error %s from server: %s", e.response.status_code, server_url)
231
+ logger.debug("Error details: %s", e)
232
+ return False
233
+ except Exception:
234
+ logger.exception("Failed to connect to MCP server via Streamable HTTP")
235
+ return False
236
+
237
+ async def _connect_with_sse(self, server_url: str) -> bool:
238
+ """Establish SSE connection to MCP server.
239
+
240
+ Args:
241
+ server_url: Full URL to MCP server SSE endpoint (e.g., https://server.com/sse)
242
+
243
+ Returns:
244
+ True if connection successful, False otherwise
245
+
246
+ Note:
247
+ - Handles long-lived SSE connections
248
+ - Implements automatic reconnection (max 3 retries)
249
+ - Parses Server-Sent Events stream
250
+ - Manages keepalive/heartbeat
251
+
252
+ """
253
+ if not server_url.startswith(("http://", "https://")):
254
+ logger.error("Invalid URL format. Must start with http:// or https://")
255
+ return False
256
+
257
+ try:
258
+ # Configure HTTP client for SSE with appropriate timeouts
259
+ client = httpx.AsyncClient(
260
+ timeout=httpx.Timeout(
261
+ connect=15.0, # Connection timeout: 15 seconds
262
+ read=None, # No read timeout for streaming (handled by keepalive)
263
+ write=30.0, # Write timeout: 30 seconds
264
+ pool=5.0, # Pool timeout: 5 seconds
265
+ ),
266
+ follow_redirects=True,
267
+ limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
268
+ )
269
+
270
+ # Establish connection using MCP SDK's sse_client
271
+ # Create a factory that ignores extra parameters since we already have a client
272
+ def client_factory(
273
+ headers: dict[str, str] | None = None,
274
+ timeout: httpx.Timeout | None = None,
275
+ auth: httpx.Auth | None = None,
276
+ ) -> httpx.AsyncClient:
277
+ return client
278
+
279
+ start_time = time.perf_counter()
280
+ read_stream, write_stream = await self.exit_stack.enter_async_context(
281
+ sse_client(server_url, httpx_client_factory=client_factory)
282
+ )
283
+
284
+ self.session = await self.exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
285
+ self.connection_time_ms = int((time.perf_counter() - start_time) * 1000)
286
+
287
+ # Store transport metadata
288
+ self.transport_type = MCPTransportType.SSE
289
+ self.url = server_url
290
+
291
+ logger.info("Successfully connected to MCP server via SSE: %s", server_url)
292
+ return True
293
+
294
+ except httpx.ConnectError as e:
295
+ logger.exception("Connection refused or server unreachable: %s", server_url)
296
+ logger.debug("Error details: %s", e)
297
+ return False
298
+ except httpx.TimeoutException as e:
299
+ logger.exception("Connection timeout for server: %s", server_url)
300
+ logger.debug("Error details: %s", e)
301
+ return False
302
+ except httpx.HTTPStatusError as e:
303
+ logger.exception("HTTP error %s from server: %s", e.response.status_code, server_url)
304
+ logger.debug("Error details: %s", e)
305
+ return False
306
+ except Exception:
307
+ logger.exception("Failed to connect to MCP server via SSE")
308
+ return False
309
+
310
+ async def initialize(self) -> InitializeResult | None:
311
+ """Initialize the MCP server session.
312
+
313
+ Performs the MCP handshake and retrieves server capabilities and information.
314
+
315
+ Returns:
316
+ InitializeResult containing server info, capabilities, and protocol version,
317
+ or None if initialization failed
318
+
319
+ Note:
320
+ Must be called after successfully connecting to a server
321
+
322
+ """
323
+ if not self.session:
324
+ logger.error(ERROR_NO_ACTIVE_SESSION)
325
+ return None
326
+
327
+ try:
328
+ init_result: InitializeResult = await self.session.initialize()
329
+ return init_result
330
+ except Exception:
331
+ logger.exception("Failed to initialize MCP server")
332
+ return None
333
+
334
+ async def list_tools(self) -> list[Tool] | None:
335
+ """List and display all available tools from the MCP server.
336
+
337
+ Retrieves the server's tools and logs detailed information about
338
+ each available tool, including name, description, and input schema.
339
+
340
+ Note:
341
+ Must be called after successfully initializing the server session
342
+
343
+ """
344
+ if not self.session:
345
+ logger.error(ERROR_NO_ACTIVE_SESSION)
346
+ return None
347
+
348
+ try:
349
+ response: ListToolsResult = await self.session.list_tools()
350
+ # TODO: Add support for nextCursor
351
+ return response.tools
352
+ except Exception:
353
+ logger.exception("Failed to list tools from the MCP server")
354
+ return None
355
+
356
+ async def list_resources(self) -> list[Resource] | None:
357
+ """List and display all available resources from the MCP server.
358
+
359
+ Retrieves the server's resources
360
+
361
+ Note:
362
+ Must be called after successfully initializing the server session
363
+
364
+ """
365
+ if not self.session:
366
+ logger.error(ERROR_NO_ACTIVE_SESSION)
367
+ return None
368
+
369
+ try:
370
+ response: ListResourcesResult = await self.session.list_resources()
371
+ # TODO: Add support for nextCursor
372
+ return response.resources
373
+ except Exception:
374
+ logger.exception("Failed to list resources from the MCP server")
375
+ return None
376
+
377
+ async def list_prompts(self) -> list[Prompt] | None:
378
+ """List and display all available prompts from the MCP server.
379
+
380
+ Retrieves the server's prompts
381
+
382
+ Note:
383
+ Must be called after successfully initializing the server session
384
+
385
+ """
386
+ if not self.session:
387
+ logger.error(ERROR_NO_ACTIVE_SESSION)
388
+ return None
389
+
390
+ try:
391
+ response: ListPromptsResult = await self.session.list_prompts()
392
+ # TODO: Add support for nextCursor
393
+ return response.prompts
394
+ except Exception:
395
+ logger.exception("Failed to list prompts from the MCP server")
396
+ return None
397
+
398
+ async def cleanup(self) -> None:
399
+ """Clean up client resources and close all connections.
400
+
401
+ Properly closes the async exit stack, which will:
402
+ - Close the stdio transport
403
+ - Close the client session
404
+ - Clean up any other managed resources
405
+
406
+ Should be called when the client is no longer needed to prevent resource leaks.
407
+ """
408
+ await self.exit_stack.aclose()
mcpscore/py.typed ADDED
File without changes
@@ -0,0 +1,89 @@
1
+ """MCP audit rules package.
2
+
3
+ This package contains the rule system for MCP server auditing:
4
+
5
+ - BaseRule: Abstract base class for all audit rules
6
+ - RuleResult: Container for rule execution results
7
+ - RuleSeverity: Severity levels for rule classification
8
+ - AuditData: Container for server data used in audits
9
+ - RuleRegistry: Registry for managing and creating rules
10
+ - Specific rule implementations for protocol version and server info checks
11
+
12
+ The rule system is designed to be extensible, allowing easy addition of new
13
+ audit checks by implementing the BaseRule interface.
14
+ """
15
+
16
+ from .base import (
17
+ AuditData,
18
+ BaseRule,
19
+ RuleResult,
20
+ RuleSeverity,
21
+ )
22
+ from .capabilities import (
23
+ CapabilityLoggingPresentRule,
24
+ CapabilityPromptsListChangedRule,
25
+ CapabilityPromptsPresentRule,
26
+ CapabilityResourcesListChangedRule,
27
+ CapabilityResourcesPresentRule,
28
+ CapabilityResourcesSubscribeRule,
29
+ CapabilityToolsListChangedRule,
30
+ )
31
+ from .protocol_version import (
32
+ AllowedVersionRule,
33
+ DeprecatedVersionRule,
34
+ LatestVersionRule,
35
+ )
36
+ from .registry import RuleRegistry, create_all_rules
37
+ from .security import (
38
+ ErrorDataLeakRule,
39
+ MalformedRequestHandlingRule,
40
+ TLSEnabledRule,
41
+ )
42
+ from .server_info import (
43
+ ServerNamePresentRule,
44
+ ServerTitlePresentRule,
45
+ ServerVersionPresentRule,
46
+ )
47
+ from .tools import (
48
+ ToolsAtLeastOneRule,
49
+ ToolsDescriptionPresentRule,
50
+ ToolsInputSchemaValidRule,
51
+ ToolsNamePresentRule,
52
+ ToolsOutputSchemaValidRule,
53
+ ToolsTitlePresentRule,
54
+ )
55
+ from .transport import (
56
+ SSETransportSupportRule,
57
+ )
58
+
59
+ __all__ = (
60
+ "AllowedVersionRule",
61
+ "AuditData",
62
+ "BaseRule",
63
+ "CapabilityLoggingPresentRule",
64
+ "CapabilityPromptsListChangedRule",
65
+ "CapabilityPromptsPresentRule",
66
+ "CapabilityResourcesListChangedRule",
67
+ "CapabilityResourcesPresentRule",
68
+ "CapabilityResourcesSubscribeRule",
69
+ "CapabilityToolsListChangedRule",
70
+ "DeprecatedVersionRule",
71
+ "ErrorDataLeakRule",
72
+ "LatestVersionRule",
73
+ "MalformedRequestHandlingRule",
74
+ "RuleRegistry",
75
+ "RuleResult",
76
+ "RuleSeverity",
77
+ "SSETransportSupportRule",
78
+ "ServerNamePresentRule",
79
+ "ServerTitlePresentRule",
80
+ "ServerVersionPresentRule",
81
+ "TLSEnabledRule",
82
+ "ToolsAtLeastOneRule",
83
+ "ToolsDescriptionPresentRule",
84
+ "ToolsInputSchemaValidRule",
85
+ "ToolsNamePresentRule",
86
+ "ToolsOutputSchemaValidRule",
87
+ "ToolsTitlePresentRule",
88
+ "create_all_rules",
89
+ )