mseep-rmcp 0.3.3__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.
rmcp/core/schemas.py ADDED
@@ -0,0 +1,156 @@
1
+ """
2
+ JSON Schema validation helpers.
3
+
4
+ Provides utilities for:
5
+ - Schema validation with proper MCP error codes (-32602)
6
+ - Common schema patterns for statistical tools
7
+ - Type conversion helpers
8
+ """
9
+
10
+ from typing import Any, Dict, List, Optional, Union
11
+ import json
12
+ import jsonschema
13
+ from jsonschema import validate, ValidationError as JsonSchemaValidationError
14
+
15
+
16
+ class SchemaError(Exception):
17
+ """Schema validation error with MCP error code."""
18
+
19
+ def __init__(self, message: str, field: Optional[str] = None):
20
+ super().__init__(message)
21
+ self.field = field
22
+ self.code = -32602 # JSON-RPC invalid params error
23
+
24
+
25
+ def validate_schema(data: Any, schema: Dict[str, Any], context: str = "") -> None:
26
+ """
27
+ Validate data against JSON schema.
28
+
29
+ Raises SchemaError with MCP-compatible error code on failure.
30
+ """
31
+ try:
32
+ validate(instance=data, schema=schema)
33
+ except JsonSchemaValidationError as e:
34
+ field_path = ".".join(str(p) for p in e.absolute_path)
35
+ error_context = f" in {context}" if context else ""
36
+ field_info = f" (field: {field_path})" if field_path else ""
37
+
38
+ raise SchemaError(
39
+ f"Schema validation failed{error_context}: {e.message}{field_info}",
40
+ field=field_path
41
+ ) from e
42
+ except Exception as e:
43
+ raise SchemaError(f"Schema validation error: {str(e)}") from e
44
+
45
+
46
+ # Common schema patterns for statistical tools
47
+
48
+ def table_schema(required_columns: Optional[List[str]] = None) -> Dict[str, Any]:
49
+ """Schema for tabular data (dict with column arrays)."""
50
+ schema = {
51
+ "type": "object",
52
+ "properties": {},
53
+ "additionalProperties": {
54
+ "type": "array",
55
+ "items": {"type": ["number", "string", "null"]}
56
+ }
57
+ }
58
+
59
+ if required_columns:
60
+ schema["required"] = required_columns
61
+ for col in required_columns:
62
+ schema["properties"][col] = {
63
+ "type": "array",
64
+ "items": {"type": ["number", "string", "null"]}
65
+ }
66
+
67
+ return schema
68
+
69
+
70
+ def formula_schema() -> Dict[str, Any]:
71
+ """Schema for R formula strings."""
72
+ return {
73
+ "type": "string",
74
+ "pattern": r"^[^~]+~[^~]+$",
75
+ "description": "R formula (e.g., 'y ~ x1 + x2')"
76
+ }
77
+
78
+
79
+ def numeric_array_schema(min_length: int = 1) -> Dict[str, Any]:
80
+ """Schema for numeric arrays."""
81
+ return {
82
+ "type": "array",
83
+ "items": {"type": "number"},
84
+ "minItems": min_length
85
+ }
86
+
87
+
88
+ def positive_number_schema() -> Dict[str, Any]:
89
+ """Schema for positive numbers."""
90
+ return {
91
+ "type": "number",
92
+ "minimum": 0,
93
+ "exclusiveMinimum": True
94
+ }
95
+
96
+
97
+ def confidence_level_schema() -> Dict[str, Any]:
98
+ """Schema for confidence levels (0-1)."""
99
+ return {
100
+ "type": "number",
101
+ "minimum": 0,
102
+ "maximum": 1,
103
+ "exclusiveMinimum": True,
104
+ "exclusiveMaximum": True
105
+ }
106
+
107
+
108
+ def choice_schema(choices: List[str]) -> Dict[str, Any]:
109
+ """Schema for enumerated choices."""
110
+ return {
111
+ "type": "string",
112
+ "enum": choices
113
+ }
114
+
115
+
116
+ # Tool result schemas
117
+
118
+ def statistical_result_schema() -> Dict[str, Any]:
119
+ """Base schema for statistical analysis results."""
120
+ return {
121
+ "type": "object",
122
+ "properties": {
123
+ "success": {"type": "boolean"},
124
+ "message": {"type": "string"},
125
+ "data": {"type": "object"},
126
+ "metadata": {
127
+ "type": "object",
128
+ "properties": {
129
+ "method": {"type": "string"},
130
+ "n_obs": {"type": "integer", "minimum": 0},
131
+ "timestamp": {"type": "string", "format": "date-time"}
132
+ }
133
+ }
134
+ },
135
+ "required": ["success"]
136
+ }
137
+
138
+
139
+ def error_result_schema() -> Dict[str, Any]:
140
+ """Schema for error results."""
141
+ return {
142
+ "type": "object",
143
+ "properties": {
144
+ "success": {"const": False},
145
+ "error": {
146
+ "type": "object",
147
+ "properties": {
148
+ "type": {"type": "string"},
149
+ "message": {"type": "string"},
150
+ "details": {"type": "object"}
151
+ },
152
+ "required": ["type", "message"]
153
+ }
154
+ },
155
+ "required": ["success", "error"]
156
+ }
rmcp/core/server.py ADDED
@@ -0,0 +1,261 @@
1
+ """
2
+ MCP Server shell with lifecycle hooks.
3
+
4
+ This module provides the main server class that:
5
+ - Initializes the MCP app using official SDK
6
+ - Manages lifespan hooks (startup/shutdown)
7
+ - Composes transports at the edge
8
+ - Centralizes registry management
9
+
10
+ Following the principle: "A single shell centralizes initialization and teardown."
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ from typing import Any, Dict, Optional, Callable, Awaitable, List
16
+ from pathlib import Path
17
+ import sys
18
+
19
+ # Official MCP SDK imports (to be added when SDK is available)
20
+ # from mcp import Server, initialize_server
21
+ # from mcp.types import Request, Response, Notification
22
+
23
+ from .context import Context, LifespanState, RequestState
24
+ from ..registries.tools import ToolsRegistry
25
+ from ..registries.resources import ResourcesRegistry
26
+ from ..registries.prompts import PromptsRegistry
27
+ from ..security.vfs import VFS
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class MCPServer:
33
+ """
34
+ Main MCP server shell that manages lifecycle and registries.
35
+
36
+ Centralizes:
37
+ - Lifespan management (startup/shutdown)
38
+ - Registry composition (tools/resources/prompts)
39
+ - Security policy enforcement
40
+ - Transport-agnostic request handling
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ name: str = "RMCP MCP Server",
46
+ version: str = "0.3.2",
47
+ description: str = "R-based statistical analysis MCP server",
48
+ ):
49
+ self.name = name
50
+ self.version = version
51
+ self.description = description
52
+
53
+ # Lifespan state
54
+ self.lifespan_state = LifespanState()
55
+
56
+ # Registries
57
+ self.tools = ToolsRegistry()
58
+ self.resources = ResourcesRegistry()
59
+ self.prompts = PromptsRegistry()
60
+
61
+ # Security
62
+ self.vfs: Optional[VFS] = None
63
+
64
+ # Callbacks
65
+ self._startup_callbacks: List[Callable[[], Awaitable[None]]] = []
66
+ self._shutdown_callbacks: List[Callable[[], Awaitable[None]]] = []
67
+
68
+ # Request tracking for cancellation
69
+ self._active_requests: Dict[str, RequestState] = {}
70
+
71
+ def configure(
72
+ self,
73
+ allowed_paths: Optional[List[str]] = None,
74
+ cache_root: Optional[str] = None,
75
+ read_only: bool = True,
76
+ **settings: Any,
77
+ ) -> "MCPServer":
78
+ """Configure server settings."""
79
+
80
+ if allowed_paths:
81
+ self.lifespan_state.allowed_paths = [Path(p) for p in allowed_paths]
82
+
83
+ if cache_root:
84
+ cache_path = Path(cache_root)
85
+ cache_path.mkdir(parents=True, exist_ok=True)
86
+ self.lifespan_state.cache_root = cache_path
87
+
88
+ self.lifespan_state.read_only = read_only
89
+ self.lifespan_state.settings.update(settings)
90
+
91
+ # Initialize VFS
92
+ self.vfs = VFS(
93
+ allowed_roots=self.lifespan_state.allowed_paths,
94
+ read_only=read_only
95
+ )
96
+
97
+ return self
98
+
99
+ def on_startup(self, func: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]:
100
+ """Register startup callback."""
101
+ self._startup_callbacks.append(func)
102
+ return func
103
+
104
+ def on_shutdown(self, func: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]:
105
+ """Register shutdown callback."""
106
+ self._shutdown_callbacks.append(func)
107
+ return func
108
+
109
+ async def startup(self) -> None:
110
+ """Run startup callbacks."""
111
+ logger.info(f"Starting {self.name} v{self.version}")
112
+
113
+ for callback in self._startup_callbacks:
114
+ await callback()
115
+
116
+ logger.info("Server startup complete")
117
+
118
+ async def shutdown(self) -> None:
119
+ """Run shutdown callbacks."""
120
+ logger.info("Shutting down server")
121
+
122
+ # Cancel active requests
123
+ for request in self._active_requests.values():
124
+ request.cancel()
125
+
126
+ # Run shutdown callbacks
127
+ for callback in self._shutdown_callbacks:
128
+ try:
129
+ await callback()
130
+ except Exception as e:
131
+ logger.error(f"Error in shutdown callback: {e}")
132
+
133
+ logger.info("Server shutdown complete")
134
+
135
+ def create_context(
136
+ self,
137
+ request_id: str,
138
+ method: str,
139
+ progress_token: Optional[str] = None,
140
+ ) -> Context:
141
+ """Create request context."""
142
+
143
+ async def progress_callback(message: str, current: int, total: int) -> None:
144
+ # TODO: Send MCP progress notification
145
+ logger.info(f"Progress {request_id}: {message} ({current}/{total})")
146
+
147
+ async def log_callback(level: str, message: str, data: Dict[str, Any]) -> None:
148
+ # TODO: Send MCP log notification
149
+ log_level = getattr(logging, level.upper(), logging.INFO)
150
+ logger.log(log_level, f"{request_id}: {message} {data}")
151
+
152
+ context = Context.create(
153
+ request_id=request_id,
154
+ method=method,
155
+ lifespan_state=self.lifespan_state,
156
+ progress_token=progress_token,
157
+ progress_callback=progress_callback,
158
+ log_callback=log_callback,
159
+ )
160
+
161
+ # Track request for cancellation
162
+ self._active_requests[request_id] = context.request
163
+
164
+ return context
165
+
166
+ def finish_request(self, request_id: str) -> None:
167
+ """Clean up request tracking."""
168
+ self._active_requests.pop(request_id, None)
169
+
170
+ async def cancel_request(self, request_id: str) -> None:
171
+ """Cancel active request."""
172
+ if request_id in self._active_requests:
173
+ self._active_requests[request_id].cancel()
174
+ logger.info(f"Cancelled request {request_id}")
175
+
176
+ async def handle_request(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
177
+ """
178
+ Handle incoming MCP request.
179
+
180
+ This is the main dispatch point that routes requests to appropriate handlers.
181
+ Returns None for notifications (no response expected).
182
+ """
183
+ method = request.get("method")
184
+ request_id = request.get("id")
185
+ params = request.get("params", {})
186
+
187
+ # Handle notifications (no response expected)
188
+ if request_id is None:
189
+ await self._handle_notification(method, params)
190
+ return None
191
+
192
+ try:
193
+ context = self.create_context(request_id, method)
194
+
195
+ # Route to appropriate handler
196
+ if method == "tools/list":
197
+ result = await self.tools.list_tools(context)
198
+ elif method == "tools/call":
199
+ tool_name = params.get("name")
200
+ arguments = params.get("arguments", {})
201
+ result = await self.tools.call_tool(context, tool_name, arguments)
202
+ elif method == "resources/list":
203
+ result = await self.resources.list_resources(context)
204
+ elif method == "resources/read":
205
+ uri = params.get("uri")
206
+ result = await self.resources.read_resource(context, uri)
207
+ elif method == "prompts/list":
208
+ result = await self.prompts.list_prompts(context)
209
+ elif method == "prompts/get":
210
+ name = params.get("name")
211
+ arguments = params.get("arguments", {})
212
+ result = await self.prompts.get_prompt(context, name, arguments)
213
+ else:
214
+ raise ValueError(f"Unknown method: {method}")
215
+
216
+ return {
217
+ "jsonrpc": "2.0",
218
+ "id": request_id,
219
+ "result": result
220
+ }
221
+
222
+ except Exception as e:
223
+ logger.error(f"Error handling request {request_id}: {e}")
224
+ return {
225
+ "jsonrpc": "2.0",
226
+ "id": request_id,
227
+ "error": {
228
+ "code": -32603, # Internal error
229
+ "message": str(e)
230
+ }
231
+ }
232
+
233
+ finally:
234
+ if request_id:
235
+ self.finish_request(request_id)
236
+
237
+ async def _handle_notification(self, method: str, params: Dict[str, Any]) -> None:
238
+ """Handle notification messages (no response expected)."""
239
+ logger.info(f"Received notification: {method}")
240
+
241
+ if method == "notifications/cancelled":
242
+ # Handle cancellation notification
243
+ request_id = params.get("requestId")
244
+ if request_id:
245
+ await self.cancel_request(request_id)
246
+
247
+ elif method == "notifications/initialized":
248
+ # MCP initialization complete
249
+ logger.info("MCP client initialization complete")
250
+
251
+ else:
252
+ logger.warning(f"Unknown notification method: {method}")
253
+
254
+
255
+ def create_server(
256
+ name: str = "RMCP MCP Server",
257
+ version: str = "0.3.2",
258
+ description: str = "R-based statistical analysis MCP server",
259
+ ) -> MCPServer:
260
+ """Create and return a new MCP server instance."""
261
+ return MCPServer(name=name, version=version, description=description)
@@ -0,0 +1,8 @@
1
+ """
2
+ R integration assets.
3
+
4
+ Contains R scripts and assets needed for:
5
+ - Package installation
6
+ - R environment setup
7
+ - Statistical computation resources
8
+ """
rmcp/r_integration.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Clean R integration for statistical analysis.
3
+
4
+ Extracted working R execution functionality without dependencies.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ import tempfile
10
+ import subprocess
11
+ import logging
12
+ from typing import Dict, Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class RExecutionError(Exception):
18
+ """Exception raised when R script execution fails."""
19
+
20
+ def __init__(self, message: str, stdout: str = "", stderr: str = "", returncode: int = None):
21
+ super().__init__(message)
22
+ self.stdout = stdout
23
+ self.stderr = stderr
24
+ self.returncode = returncode
25
+
26
+
27
+ def execute_r_script(script: str, args: Dict[str, Any]) -> Dict[str, Any]:
28
+ """
29
+ Execute an R script with the given arguments and return the results.
30
+
31
+ Args:
32
+ script: R script code to execute
33
+ args: Dictionary of arguments to pass to the R script
34
+
35
+ Returns:
36
+ Dictionary containing the results from R script execution
37
+
38
+ Raises:
39
+ RExecutionError: If R script execution fails
40
+ FileNotFoundError: If R is not installed or not in PATH
41
+ json.JSONDecodeError: If R script returns invalid JSON
42
+ """
43
+ with tempfile.NamedTemporaryFile(suffix='.R', delete=False, mode='w') as script_file, \
44
+ tempfile.NamedTemporaryFile(suffix='.json', delete=False, mode='w') as args_file, \
45
+ tempfile.NamedTemporaryFile(suffix='.json', delete=False) as result_file:
46
+
47
+ script_path = script_file.name
48
+ args_path = args_file.name
49
+ result_path = result_file.name
50
+
51
+ try:
52
+ # Write arguments to JSON file
53
+ json.dump(args, args_file, default=str)
54
+ args_file.flush()
55
+
56
+ # Create complete R script
57
+ full_script = f'''
58
+ # Load required libraries
59
+ library(jsonlite)
60
+
61
+ # Load arguments
62
+ args <- fromJSON("{args_path}")
63
+
64
+ # User script
65
+ {script}
66
+
67
+ # Write result
68
+ write_json(result, "{result_path}", auto_unbox = TRUE)
69
+ '''
70
+
71
+ script_file.write(full_script)
72
+ script_file.flush()
73
+
74
+ logger.debug(f"Executing R script with args: {args}")
75
+
76
+ # Execute R script
77
+ process = subprocess.run(
78
+ ['R', '--slave', '--no-restore', '--file=' + script_path],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=120
82
+ )
83
+
84
+ if process.returncode != 0:
85
+ error_msg = f"R script failed with return code {process.returncode}"
86
+ logger.error(f"{error_msg}\\nStderr: {process.stderr}")
87
+ raise RExecutionError(
88
+ error_msg,
89
+ stdout=process.stdout,
90
+ stderr=process.stderr,
91
+ returncode=process.returncode
92
+ )
93
+
94
+ # Read results
95
+ try:
96
+ with open(result_path, 'r') as f:
97
+ result = json.load(f)
98
+ logger.debug(f"R script executed successfully, result: {result}")
99
+ return result
100
+ except FileNotFoundError:
101
+ raise RExecutionError("R script did not produce output file")
102
+ except json.JSONDecodeError as e:
103
+ raise RExecutionError(f"R script produced invalid JSON: {e}")
104
+
105
+ finally:
106
+ # Cleanup temporary files
107
+ for temp_path in [script_path, args_path, result_path]:
108
+ try:
109
+ os.unlink(temp_path)
110
+ logger.debug(f"Cleaned up temporary file: {temp_path}")
111
+ except OSError:
112
+ pass
@@ -0,0 +1,26 @@
1
+ """
2
+ Registry system for MCP server capabilities.
3
+
4
+ The registry pattern provides clean separation between:
5
+ - Protocol concerns (MCP message handling)
6
+ - Registry concerns (capability discovery and dispatch)
7
+ - Domain concerns (actual tool/resource/prompt logic)
8
+
9
+ This enables:
10
+ - Independent testing of domain logic
11
+ - Clean capability declaration
12
+ - Type-safe interfaces
13
+ """
14
+
15
+ from .tools import ToolsRegistry, tool
16
+ from .resources import ResourcesRegistry, resource
17
+ from .prompts import PromptsRegistry, prompt
18
+
19
+ __all__ = [
20
+ "ToolsRegistry",
21
+ "ResourcesRegistry",
22
+ "PromptsRegistry",
23
+ "tool",
24
+ "resource",
25
+ "prompt",
26
+ ]