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.
- mseep_rmcp-0.3.3.dist-info/METADATA +50 -0
- mseep_rmcp-0.3.3.dist-info/RECORD +34 -0
- mseep_rmcp-0.3.3.dist-info/WHEEL +5 -0
- mseep_rmcp-0.3.3.dist-info/entry_points.txt +2 -0
- mseep_rmcp-0.3.3.dist-info/licenses/LICENSE +21 -0
- mseep_rmcp-0.3.3.dist-info/top_level.txt +1 -0
- rmcp/__init__.py +31 -0
- rmcp/cli.py +317 -0
- rmcp/core/__init__.py +14 -0
- rmcp/core/context.py +150 -0
- rmcp/core/schemas.py +156 -0
- rmcp/core/server.py +261 -0
- rmcp/r_assets/__init__.py +8 -0
- rmcp/r_integration.py +112 -0
- rmcp/registries/__init__.py +26 -0
- rmcp/registries/prompts.py +316 -0
- rmcp/registries/resources.py +266 -0
- rmcp/registries/tools.py +223 -0
- rmcp/scripts/__init__.py +9 -0
- rmcp/security/__init__.py +15 -0
- rmcp/security/vfs.py +233 -0
- rmcp/tools/descriptive.py +279 -0
- rmcp/tools/econometrics.py +250 -0
- rmcp/tools/fileops.py +315 -0
- rmcp/tools/machine_learning.py +299 -0
- rmcp/tools/regression.py +287 -0
- rmcp/tools/statistical_tests.py +332 -0
- rmcp/tools/timeseries.py +239 -0
- rmcp/tools/transforms.py +293 -0
- rmcp/tools/visualization.py +590 -0
- rmcp/transport/__init__.py +16 -0
- rmcp/transport/base.py +130 -0
- rmcp/transport/jsonrpc.py +243 -0
- rmcp/transport/stdio.py +201 -0
rmcp/registries/tools.py
ADDED
@@ -0,0 +1,223 @@
|
|
1
|
+
"""
|
2
|
+
Tools registry for MCP server.
|
3
|
+
|
4
|
+
Provides:
|
5
|
+
- @tool decorator for declarative tool registration
|
6
|
+
- Schema validation with proper error codes
|
7
|
+
- Tool discovery and dispatch
|
8
|
+
- Context-aware execution
|
9
|
+
|
10
|
+
Following the principle: "Registries are discoverable and testable."
|
11
|
+
"""
|
12
|
+
|
13
|
+
from typing import Any, Dict, List, Optional, Callable, Awaitable, Union
|
14
|
+
from functools import wraps
|
15
|
+
from dataclasses import dataclass
|
16
|
+
import inspect
|
17
|
+
import logging
|
18
|
+
|
19
|
+
from ..core.context import Context
|
20
|
+
from ..core.schemas import validate_schema, SchemaError, statistical_result_schema
|
21
|
+
|
22
|
+
logger = logging.getLogger(__name__)
|
23
|
+
|
24
|
+
|
25
|
+
@dataclass
|
26
|
+
class ToolDefinition:
|
27
|
+
"""Tool metadata and handler."""
|
28
|
+
|
29
|
+
name: str
|
30
|
+
handler: Callable[[Context, Dict[str, Any]], Awaitable[Dict[str, Any]]]
|
31
|
+
input_schema: Dict[str, Any]
|
32
|
+
output_schema: Optional[Dict[str, Any]] = None
|
33
|
+
title: Optional[str] = None
|
34
|
+
description: Optional[str] = None
|
35
|
+
annotations: Optional[Dict[str, Any]] = None
|
36
|
+
|
37
|
+
|
38
|
+
class ToolsRegistry:
|
39
|
+
"""Registry for MCP tools with schema validation."""
|
40
|
+
|
41
|
+
def __init__(self):
|
42
|
+
self._tools: Dict[str, ToolDefinition] = {}
|
43
|
+
|
44
|
+
def register(
|
45
|
+
self,
|
46
|
+
name: str,
|
47
|
+
handler: Callable[[Context, Dict[str, Any]], Awaitable[Dict[str, Any]]],
|
48
|
+
input_schema: Dict[str, Any],
|
49
|
+
output_schema: Optional[Dict[str, Any]] = None,
|
50
|
+
title: Optional[str] = None,
|
51
|
+
description: Optional[str] = None,
|
52
|
+
annotations: Optional[Dict[str, Any]] = None,
|
53
|
+
) -> None:
|
54
|
+
"""Register a tool with the registry."""
|
55
|
+
|
56
|
+
if name in self._tools:
|
57
|
+
logger.warning(f"Tool '{name}' already registered, overwriting")
|
58
|
+
|
59
|
+
self._tools[name] = ToolDefinition(
|
60
|
+
name=name,
|
61
|
+
handler=handler,
|
62
|
+
input_schema=input_schema,
|
63
|
+
output_schema=output_schema,
|
64
|
+
title=title or name,
|
65
|
+
description=description or f"Execute {name}",
|
66
|
+
annotations=annotations or {},
|
67
|
+
)
|
68
|
+
|
69
|
+
logger.debug(f"Registered tool: {name}")
|
70
|
+
|
71
|
+
async def list_tools(self, context: Context) -> Dict[str, Any]:
|
72
|
+
"""List available tools for MCP tools/list."""
|
73
|
+
|
74
|
+
tools = []
|
75
|
+
for tool_def in self._tools.values():
|
76
|
+
tool_info = {
|
77
|
+
"name": tool_def.name,
|
78
|
+
"title": tool_def.title,
|
79
|
+
"description": tool_def.description,
|
80
|
+
"inputSchema": tool_def.input_schema,
|
81
|
+
}
|
82
|
+
|
83
|
+
if tool_def.output_schema:
|
84
|
+
tool_info["outputSchema"] = tool_def.output_schema
|
85
|
+
|
86
|
+
if tool_def.annotations:
|
87
|
+
tool_info["annotations"] = tool_def.annotations
|
88
|
+
|
89
|
+
tools.append(tool_info)
|
90
|
+
|
91
|
+
await context.info(f"Listed {len(tools)} available tools")
|
92
|
+
|
93
|
+
return {"tools": tools}
|
94
|
+
|
95
|
+
async def call_tool(
|
96
|
+
self,
|
97
|
+
context: Context,
|
98
|
+
name: str,
|
99
|
+
arguments: Dict[str, Any]
|
100
|
+
) -> Dict[str, Any]:
|
101
|
+
"""Call a tool with validation."""
|
102
|
+
|
103
|
+
if name not in self._tools:
|
104
|
+
raise ValueError(f"Unknown tool: {name}")
|
105
|
+
|
106
|
+
tool_def = self._tools[name]
|
107
|
+
|
108
|
+
try:
|
109
|
+
# Validate input schema
|
110
|
+
validate_schema(arguments, tool_def.input_schema, f"tool '{name}' arguments")
|
111
|
+
|
112
|
+
await context.info(f"Calling tool: {name}", arguments=arguments)
|
113
|
+
|
114
|
+
# Check cancellation before execution
|
115
|
+
context.check_cancellation()
|
116
|
+
|
117
|
+
# Execute tool handler
|
118
|
+
result = await tool_def.handler(context, arguments)
|
119
|
+
|
120
|
+
# Validate output schema if provided
|
121
|
+
if tool_def.output_schema:
|
122
|
+
validate_schema(result, tool_def.output_schema, f"tool '{name}' output")
|
123
|
+
|
124
|
+
await context.info(f"Tool completed: {name}")
|
125
|
+
|
126
|
+
return {
|
127
|
+
"content": [
|
128
|
+
{
|
129
|
+
"type": "text",
|
130
|
+
"text": str(result)
|
131
|
+
}
|
132
|
+
]
|
133
|
+
}
|
134
|
+
|
135
|
+
except SchemaError as e:
|
136
|
+
await context.error(f"Schema validation failed for tool '{name}': {e}")
|
137
|
+
return {
|
138
|
+
"content": [
|
139
|
+
{
|
140
|
+
"type": "text",
|
141
|
+
"text": f"Error: {e}"
|
142
|
+
}
|
143
|
+
],
|
144
|
+
"isError": True
|
145
|
+
}
|
146
|
+
|
147
|
+
except Exception as e:
|
148
|
+
await context.error(f"Tool execution failed for '{name}': {e}")
|
149
|
+
return {
|
150
|
+
"content": [
|
151
|
+
{
|
152
|
+
"type": "text",
|
153
|
+
"text": f"Tool execution error: {e}"
|
154
|
+
}
|
155
|
+
],
|
156
|
+
"isError": True
|
157
|
+
}
|
158
|
+
|
159
|
+
|
160
|
+
def tool(
|
161
|
+
name: str,
|
162
|
+
input_schema: Dict[str, Any],
|
163
|
+
output_schema: Optional[Dict[str, Any]] = None,
|
164
|
+
title: Optional[str] = None,
|
165
|
+
description: Optional[str] = None,
|
166
|
+
annotations: Optional[Dict[str, Any]] = None,
|
167
|
+
):
|
168
|
+
"""
|
169
|
+
Decorator to register a function as an MCP tool.
|
170
|
+
|
171
|
+
Usage:
|
172
|
+
@tool(
|
173
|
+
name="analyze_data",
|
174
|
+
input_schema={
|
175
|
+
"type": "object",
|
176
|
+
"properties": {
|
177
|
+
"data": table_schema(),
|
178
|
+
"method": choice_schema(["mean", "median", "mode"])
|
179
|
+
},
|
180
|
+
"required": ["data"]
|
181
|
+
},
|
182
|
+
description="Analyze dataset with specified method"
|
183
|
+
)
|
184
|
+
async def analyze_data(context: Context, params: Dict[str, Any]) -> Dict[str, Any]:
|
185
|
+
# Tool implementation
|
186
|
+
return {"result": "analysis complete"}
|
187
|
+
"""
|
188
|
+
|
189
|
+
def decorator(func: Callable[[Context, Dict[str, Any]], Awaitable[Dict[str, Any]]]):
|
190
|
+
|
191
|
+
# Ensure function is async
|
192
|
+
if not inspect.iscoroutinefunction(func):
|
193
|
+
raise ValueError(f"Tool handler '{name}' must be an async function")
|
194
|
+
|
195
|
+
# Store tool metadata on function for registration
|
196
|
+
func._mcp_tool_name = name
|
197
|
+
func._mcp_tool_input_schema = input_schema
|
198
|
+
func._mcp_tool_output_schema = output_schema
|
199
|
+
func._mcp_tool_title = title
|
200
|
+
func._mcp_tool_description = description
|
201
|
+
func._mcp_tool_annotations = annotations
|
202
|
+
|
203
|
+
return func
|
204
|
+
|
205
|
+
return decorator
|
206
|
+
|
207
|
+
|
208
|
+
def register_tool_functions(registry: ToolsRegistry, *functions) -> None:
|
209
|
+
"""Register multiple functions decorated with @tool."""
|
210
|
+
|
211
|
+
for func in functions:
|
212
|
+
if hasattr(func, '_mcp_tool_name'):
|
213
|
+
registry.register(
|
214
|
+
name=func._mcp_tool_name,
|
215
|
+
handler=func,
|
216
|
+
input_schema=func._mcp_tool_input_schema,
|
217
|
+
output_schema=func._mcp_tool_output_schema,
|
218
|
+
title=func._mcp_tool_title,
|
219
|
+
description=func._mcp_tool_description,
|
220
|
+
annotations=func._mcp_tool_annotations,
|
221
|
+
)
|
222
|
+
else:
|
223
|
+
logger.warning(f"Function {func.__name__} not decorated with @tool, skipping")
|
rmcp/scripts/__init__.py
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
"""
|
2
|
+
Security components for MCP server.
|
3
|
+
|
4
|
+
Implements:
|
5
|
+
- Virtual File System (VFS) with allowlists and sandboxing
|
6
|
+
- Path traversal protection
|
7
|
+
- Content type validation
|
8
|
+
- Size limits and safety checks
|
9
|
+
|
10
|
+
Following the principle: "Security by default."
|
11
|
+
"""
|
12
|
+
|
13
|
+
from .vfs import VFS, VFSError
|
14
|
+
|
15
|
+
__all__ = ["VFS", "VFSError"]
|
rmcp/security/vfs.py
ADDED
@@ -0,0 +1,233 @@
|
|
1
|
+
"""
|
2
|
+
Virtual File System for secure file access.
|
3
|
+
|
4
|
+
Implements mature MCP server patterns:
|
5
|
+
- Explicit allowed roots (mounts)
|
6
|
+
- Path normalization and traversal checks
|
7
|
+
- MIME type detection and size caps
|
8
|
+
- Read-only enforcement
|
9
|
+
|
10
|
+
Following the pattern: "Gate filesystem access with a tiny VFS."
|
11
|
+
"""
|
12
|
+
|
13
|
+
import os
|
14
|
+
import mimetypes
|
15
|
+
from pathlib import Path
|
16
|
+
from typing import List, Optional, Dict, Any, Union
|
17
|
+
import logging
|
18
|
+
|
19
|
+
logger = logging.getLogger(__name__)
|
20
|
+
|
21
|
+
|
22
|
+
class VFSError(Exception):
|
23
|
+
"""VFS access error."""
|
24
|
+
pass
|
25
|
+
|
26
|
+
|
27
|
+
class VFS:
|
28
|
+
"""
|
29
|
+
Virtual File System with security controls.
|
30
|
+
|
31
|
+
Provides safe file access with:
|
32
|
+
- Allowlisted root directories (explicit mounts)
|
33
|
+
- Path traversal protection
|
34
|
+
- File type and size limits
|
35
|
+
- Read-only enforcement
|
36
|
+
"""
|
37
|
+
|
38
|
+
def __init__(
|
39
|
+
self,
|
40
|
+
allowed_roots: List[Path],
|
41
|
+
read_only: bool = True,
|
42
|
+
max_file_size: int = 50 * 1024 * 1024, # 50MB
|
43
|
+
allowed_mime_types: Optional[List[str]] = None,
|
44
|
+
):
|
45
|
+
self.allowed_roots = [root.resolve() for root in allowed_roots]
|
46
|
+
self.read_only = read_only
|
47
|
+
self.max_file_size = max_file_size
|
48
|
+
|
49
|
+
# Default allowed MIME types for data analysis
|
50
|
+
self.allowed_mime_types = allowed_mime_types or [
|
51
|
+
'text/plain',
|
52
|
+
'text/csv',
|
53
|
+
'application/json',
|
54
|
+
'application/xml',
|
55
|
+
'text/xml',
|
56
|
+
'application/pdf',
|
57
|
+
'text/tab-separated-values',
|
58
|
+
'application/vnd.ms-excel',
|
59
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
60
|
+
]
|
61
|
+
|
62
|
+
logger.info(
|
63
|
+
f"VFS initialized: {len(self.allowed_roots)} roots, "
|
64
|
+
f"read_only={read_only}, max_size={max_file_size}"
|
65
|
+
)
|
66
|
+
|
67
|
+
def _resolve_and_validate_path(self, path: Union[str, Path]) -> Path:
|
68
|
+
"""Resolve path and validate against allowed roots."""
|
69
|
+
|
70
|
+
try:
|
71
|
+
# Resolve path to handle symlinks and relative paths
|
72
|
+
resolved_path = Path(path).resolve()
|
73
|
+
except (OSError, ValueError) as e:
|
74
|
+
raise VFSError(f"Invalid path: {path} ({e})")
|
75
|
+
|
76
|
+
# Check if path is under any allowed root
|
77
|
+
for allowed_root in self.allowed_roots:
|
78
|
+
try:
|
79
|
+
resolved_path.relative_to(allowed_root)
|
80
|
+
return resolved_path
|
81
|
+
except ValueError:
|
82
|
+
continue
|
83
|
+
|
84
|
+
# Not under any allowed root
|
85
|
+
allowed_roots_str = ", ".join(str(root) for root in self.allowed_roots)
|
86
|
+
raise VFSError(
|
87
|
+
f"Path access denied: {resolved_path}. "
|
88
|
+
f"Allowed roots: [{allowed_roots_str}]"
|
89
|
+
)
|
90
|
+
|
91
|
+
def _check_file_constraints(self, path: Path) -> None:
|
92
|
+
"""Check file size and type constraints."""
|
93
|
+
|
94
|
+
if not path.exists():
|
95
|
+
raise VFSError(f"File not found: {path}")
|
96
|
+
|
97
|
+
if not path.is_file():
|
98
|
+
raise VFSError(f"Not a regular file: {path}")
|
99
|
+
|
100
|
+
# Check file size
|
101
|
+
file_size = path.stat().st_size
|
102
|
+
if file_size > self.max_file_size:
|
103
|
+
raise VFSError(
|
104
|
+
f"File too large: {path} ({file_size} bytes, max {self.max_file_size})"
|
105
|
+
)
|
106
|
+
|
107
|
+
# Check MIME type
|
108
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
109
|
+
if mime_type and mime_type not in self.allowed_mime_types:
|
110
|
+
raise VFSError(
|
111
|
+
f"File type not allowed: {path} ({mime_type}). "
|
112
|
+
f"Allowed types: {self.allowed_mime_types}"
|
113
|
+
)
|
114
|
+
|
115
|
+
def read_file(self, path: Union[str, Path]) -> bytes:
|
116
|
+
"""Read file with security checks."""
|
117
|
+
|
118
|
+
resolved_path = self._resolve_and_validate_path(path)
|
119
|
+
self._check_file_constraints(resolved_path)
|
120
|
+
|
121
|
+
try:
|
122
|
+
with open(resolved_path, 'rb') as f:
|
123
|
+
content = f.read()
|
124
|
+
|
125
|
+
logger.debug(f"Read file: {resolved_path} ({len(content)} bytes)")
|
126
|
+
return content
|
127
|
+
|
128
|
+
except (OSError, IOError) as e:
|
129
|
+
raise VFSError(f"Failed to read file {resolved_path}: {e}")
|
130
|
+
|
131
|
+
def read_text(self, path: Union[str, Path], encoding: str = 'utf-8') -> str:
|
132
|
+
"""Read text file with security checks."""
|
133
|
+
|
134
|
+
content = self.read_file(path)
|
135
|
+
try:
|
136
|
+
return content.decode(encoding)
|
137
|
+
except UnicodeDecodeError as e:
|
138
|
+
raise VFSError(f"Failed to decode file {path} as {encoding}: {e}")
|
139
|
+
|
140
|
+
def list_directory(self, path: Union[str, Path]) -> List[Dict[str, Any]]:
|
141
|
+
"""List directory contents with security checks."""
|
142
|
+
|
143
|
+
resolved_path = self._resolve_and_validate_path(path)
|
144
|
+
|
145
|
+
if not resolved_path.is_dir():
|
146
|
+
raise VFSError(f"Not a directory: {resolved_path}")
|
147
|
+
|
148
|
+
try:
|
149
|
+
entries = []
|
150
|
+
for entry in resolved_path.iterdir():
|
151
|
+
try:
|
152
|
+
stat = entry.stat()
|
153
|
+
mime_type, _ = mimetypes.guess_type(str(entry))
|
154
|
+
|
155
|
+
entries.append({
|
156
|
+
"name": entry.name,
|
157
|
+
"path": str(entry),
|
158
|
+
"type": "directory" if entry.is_dir() else "file",
|
159
|
+
"size": stat.st_size if entry.is_file() else None,
|
160
|
+
"modified": stat.st_mtime,
|
161
|
+
"mime_type": mime_type,
|
162
|
+
})
|
163
|
+
except (OSError, IOError):
|
164
|
+
# Skip entries we can't stat
|
165
|
+
continue
|
166
|
+
|
167
|
+
logger.debug(f"Listed directory: {resolved_path} ({len(entries)} entries)")
|
168
|
+
return entries
|
169
|
+
|
170
|
+
except (OSError, IOError) as e:
|
171
|
+
raise VFSError(f"Failed to list directory {resolved_path}: {e}")
|
172
|
+
|
173
|
+
def write_file(self, path: Union[str, Path], content: bytes) -> None:
|
174
|
+
"""Write file with security checks."""
|
175
|
+
|
176
|
+
if self.read_only:
|
177
|
+
raise VFSError("VFS is configured as read-only")
|
178
|
+
|
179
|
+
resolved_path = self._resolve_and_validate_path(path)
|
180
|
+
|
181
|
+
# Check content size
|
182
|
+
if len(content) > self.max_file_size:
|
183
|
+
raise VFSError(
|
184
|
+
f"Content too large: {len(content)} bytes, max {self.max_file_size}"
|
185
|
+
)
|
186
|
+
|
187
|
+
try:
|
188
|
+
# Ensure parent directory exists
|
189
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
190
|
+
|
191
|
+
with open(resolved_path, 'wb') as f:
|
192
|
+
f.write(content)
|
193
|
+
|
194
|
+
logger.debug(f"Wrote file: {resolved_path} ({len(content)} bytes)")
|
195
|
+
|
196
|
+
except (OSError, IOError) as e:
|
197
|
+
raise VFSError(f"Failed to write file {resolved_path}: {e}")
|
198
|
+
|
199
|
+
def write_text(self, path: Union[str, Path], content: str, encoding: str = 'utf-8') -> None:
|
200
|
+
"""Write text file with security checks."""
|
201
|
+
|
202
|
+
try:
|
203
|
+
encoded_content = content.encode(encoding)
|
204
|
+
self.write_file(path, encoded_content)
|
205
|
+
except UnicodeEncodeError as e:
|
206
|
+
raise VFSError(f"Failed to encode content as {encoding}: {e}")
|
207
|
+
|
208
|
+
def file_info(self, path: Union[str, Path]) -> Dict[str, Any]:
|
209
|
+
"""Get file information with security checks."""
|
210
|
+
|
211
|
+
resolved_path = self._resolve_and_validate_path(path)
|
212
|
+
|
213
|
+
if not resolved_path.exists():
|
214
|
+
raise VFSError(f"File not found: {resolved_path}")
|
215
|
+
|
216
|
+
try:
|
217
|
+
stat = resolved_path.stat()
|
218
|
+
mime_type, encoding = mimetypes.guess_type(str(resolved_path))
|
219
|
+
|
220
|
+
return {
|
221
|
+
"path": str(resolved_path),
|
222
|
+
"name": resolved_path.name,
|
223
|
+
"type": "directory" if resolved_path.is_dir() else "file",
|
224
|
+
"size": stat.st_size,
|
225
|
+
"modified": stat.st_mtime,
|
226
|
+
"mime_type": mime_type,
|
227
|
+
"encoding": encoding,
|
228
|
+
"readable": os.access(resolved_path, os.R_OK),
|
229
|
+
"writable": os.access(resolved_path, os.W_OK) and not self.read_only,
|
230
|
+
}
|
231
|
+
|
232
|
+
except (OSError, IOError) as e:
|
233
|
+
raise VFSError(f"Failed to get file info for {resolved_path}: {e}")
|