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.
@@ -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")
@@ -0,0 +1,9 @@
1
+ """
2
+ Build and development scripts.
3
+
4
+ Contains scripts for:
5
+ - Code formatting and linting
6
+ - Test execution
7
+ - Build processes
8
+ - Development workflows
9
+ """
@@ -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}")