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/transport/base.py ADDED
@@ -0,0 +1,130 @@
1
+ """
2
+ Base transport interface for MCP server.
3
+
4
+ Defines the contract that all transports must implement,
5
+ enabling clean composition at the server edge.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Any, Dict, Optional, AsyncIterator, Callable, Awaitable
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Transport(ABC):
16
+ """
17
+ Abstract base class for MCP transports.
18
+
19
+ All transports must implement:
20
+ - Message receiving (async iterator)
21
+ - Message sending
22
+ - Lifecycle management (startup/shutdown)
23
+ - Error handling
24
+ """
25
+
26
+ def __init__(self, name: str):
27
+ self.name = name
28
+ self._running = False
29
+ self._message_handler: Optional[Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]] = None
30
+
31
+ def set_message_handler(self, handler: Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]) -> None:
32
+ """Set the message handler that will process incoming messages."""
33
+ self._message_handler = handler
34
+
35
+ @abstractmethod
36
+ async def startup(self) -> None:
37
+ """Initialize the transport."""
38
+ logger.info(f"Starting {self.name} transport")
39
+ self._running = True
40
+
41
+ @abstractmethod
42
+ async def shutdown(self) -> None:
43
+ """Clean up the transport."""
44
+ logger.info(f"Shutting down {self.name} transport")
45
+ self._running = False
46
+
47
+ @abstractmethod
48
+ async def receive_messages(self) -> AsyncIterator[Dict[str, Any]]:
49
+ """
50
+ Async iterator that yields incoming messages.
51
+
52
+ Messages are already parsed from transport format (JSON-RPC).
53
+ """
54
+ pass
55
+
56
+ @abstractmethod
57
+ async def send_message(self, message: Dict[str, Any]) -> None:
58
+ """
59
+ Send a message via the transport.
60
+
61
+ Message will be encoded to transport format (JSON-RPC).
62
+ """
63
+ pass
64
+
65
+ async def run(self) -> None:
66
+ """
67
+ Run the transport event loop.
68
+
69
+ This is the main entry point that:
70
+ 1. Starts the transport
71
+ 2. Processes incoming messages
72
+ 3. Handles errors gracefully
73
+ 4. Ensures clean shutdown
74
+ """
75
+ if not self._message_handler:
76
+ raise RuntimeError("Message handler not set")
77
+
78
+ try:
79
+ await self.startup()
80
+
81
+ async for message in self.receive_messages():
82
+ try:
83
+ # Process message through handler
84
+ response = await self._message_handler(message)
85
+
86
+ # Send response if there is one
87
+ if response:
88
+ await self.send_message(response)
89
+
90
+ except Exception as e:
91
+ logger.error(f"Error processing message: {e}")
92
+
93
+ # Send error response if possible
94
+ error_response = self._create_error_response(message, e)
95
+ if error_response:
96
+ try:
97
+ await self.send_message(error_response)
98
+ except Exception as send_error:
99
+ logger.error(f"Failed to send error response: {send_error}")
100
+
101
+ except Exception as e:
102
+ logger.error(f"Transport error: {e}")
103
+
104
+ finally:
105
+ await self.shutdown()
106
+
107
+ def _create_error_response(self, request: Dict[str, Any], error: Exception) -> Optional[Dict[str, Any]]:
108
+ """Create an error response for a failed request."""
109
+
110
+ request_id = request.get("id")
111
+ if request_id is None:
112
+ # No ID means no response expected (notification)
113
+ return None
114
+
115
+ return {
116
+ "jsonrpc": "2.0",
117
+ "id": request_id,
118
+ "error": {
119
+ "code": -32603, # Internal error
120
+ "message": str(error),
121
+ "data": {
122
+ "type": type(error).__name__
123
+ }
124
+ }
125
+ }
126
+
127
+ @property
128
+ def is_running(self) -> bool:
129
+ """Check if transport is running."""
130
+ return self._running
@@ -0,0 +1,243 @@
1
+ """
2
+ JSON-RPC 2.0 envelope handling.
3
+
4
+ Implements proper JSON-RPC 2.0 specification:
5
+ - Request/response/notification parsing
6
+ - Error code handling per spec
7
+ - Message validation
8
+ - Single-line encoding for stdio transport
9
+
10
+ Following the principle: "No hand-rolled JSON-RPC, no 'close enough' message shapes."
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any, Dict, Optional, Union
16
+ from dataclasses import dataclass
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class JSONRPCError(Exception):
22
+ """JSON-RPC error with proper error code."""
23
+
24
+ # Standard JSON-RPC 2.0 error codes
25
+ PARSE_ERROR = -32700
26
+ INVALID_REQUEST = -32600
27
+ METHOD_NOT_FOUND = -32601
28
+ INVALID_PARAMS = -32602
29
+ INTERNAL_ERROR = -32603
30
+
31
+ def __init__(self, code: int, message: str, data: Any = None, request_id: Any = None):
32
+ super().__init__(message)
33
+ self.code = code
34
+ self.message = message
35
+ self.data = data
36
+ self.request_id = request_id
37
+
38
+ def to_dict(self) -> Dict[str, Any]:
39
+ """Convert to JSON-RPC error response format."""
40
+ error_obj = {
41
+ "code": self.code,
42
+ "message": self.message
43
+ }
44
+
45
+ if self.data is not None:
46
+ error_obj["data"] = self.data
47
+
48
+ response = {
49
+ "jsonrpc": "2.0",
50
+ "id": self.request_id,
51
+ "error": error_obj
52
+ }
53
+
54
+ return response
55
+
56
+
57
+ @dataclass
58
+ class JSONRPCMessage:
59
+ """Parsed JSON-RPC message."""
60
+
61
+ jsonrpc: str
62
+ id: Optional[Union[str, int, None]] = None
63
+ method: Optional[str] = None
64
+ params: Optional[Union[Dict[str, Any], list]] = None
65
+ result: Optional[Any] = None
66
+ error: Optional[Dict[str, Any]] = None
67
+
68
+ @property
69
+ def is_request(self) -> bool:
70
+ """Check if this is a request message."""
71
+ return self.method is not None and self.id is not None
72
+
73
+ @property
74
+ def is_notification(self) -> bool:
75
+ """Check if this is a notification message."""
76
+ return self.method is not None and self.id is None
77
+
78
+ @property
79
+ def is_response(self) -> bool:
80
+ """Check if this is a response message."""
81
+ return self.method is None and (self.result is not None or self.error is not None)
82
+
83
+
84
+ class JSONRPCEnvelope:
85
+ """JSON-RPC 2.0 message envelope handler."""
86
+
87
+ @staticmethod
88
+ def encode(message: Dict[str, Any]) -> str:
89
+ """
90
+ Encode message to single-line JSON for stdio transport.
91
+
92
+ Following the principle: "One JSON-RPC message per line."
93
+ """
94
+ try:
95
+ # Ensure no embedded newlines in the JSON
96
+ json_str = json.dumps(message, separators=(',', ':'), ensure_ascii=False)
97
+
98
+ # Validate it's actually single line
99
+ if '\n' in json_str or '\r' in json_str:
100
+ # This should not happen with separators, but be safe
101
+ json_str = json_str.replace('\n', '\\n').replace('\r', '\\r')
102
+
103
+ return json_str
104
+
105
+ except (TypeError, ValueError) as e:
106
+ raise JSONRPCError(
107
+ JSONRPCError.INTERNAL_ERROR,
108
+ f"Failed to encode JSON-RPC message: {e}",
109
+ data={"original_message": str(message)[:200]} # Truncate for safety
110
+ )
111
+
112
+ @staticmethod
113
+ def decode(line: str) -> JSONRPCMessage:
114
+ """
115
+ Decode single line of JSON to JSON-RPC message.
116
+
117
+ Validates JSON-RPC 2.0 specification compliance.
118
+ """
119
+ if not line.strip():
120
+ raise JSONRPCError(
121
+ JSONRPCError.INVALID_REQUEST,
122
+ "Empty message"
123
+ )
124
+
125
+ try:
126
+ data = json.loads(line.strip())
127
+ except json.JSONDecodeError as e:
128
+ raise JSONRPCError(
129
+ JSONRPCError.PARSE_ERROR,
130
+ f"Invalid JSON: {e}"
131
+ ) from e
132
+
133
+ # Validate JSON-RPC 2.0 structure
134
+ if not isinstance(data, dict):
135
+ raise JSONRPCError(
136
+ JSONRPCError.INVALID_REQUEST,
137
+ "JSON-RPC message must be an object"
138
+ )
139
+
140
+ jsonrpc_version = data.get("jsonrpc")
141
+ if jsonrpc_version != "2.0":
142
+ raise JSONRPCError(
143
+ JSONRPCError.INVALID_REQUEST,
144
+ f"Invalid JSON-RPC version: {jsonrpc_version}. Must be '2.0'"
145
+ )
146
+
147
+ # Extract message components
148
+ message = JSONRPCMessage(
149
+ jsonrpc=jsonrpc_version,
150
+ id=data.get("id"),
151
+ method=data.get("method"),
152
+ params=data.get("params"),
153
+ result=data.get("result"),
154
+ error=data.get("error")
155
+ )
156
+
157
+ # Validate message type
158
+ if message.is_request or message.is_notification:
159
+ if not isinstance(message.method, str):
160
+ raise JSONRPCError(
161
+ JSONRPCError.INVALID_REQUEST,
162
+ "Method must be a string",
163
+ request_id=message.id
164
+ )
165
+
166
+ # Params are optional, but if present must be object or array
167
+ if message.params is not None:
168
+ if not isinstance(message.params, (dict, list)):
169
+ raise JSONRPCError(
170
+ JSONRPCError.INVALID_REQUEST,
171
+ "Params must be object or array",
172
+ request_id=message.id
173
+ )
174
+
175
+ elif message.is_response:
176
+ # Response must have either result or error, not both
177
+ has_result = message.result is not None
178
+ has_error = message.error is not None
179
+
180
+ if has_result and has_error:
181
+ raise JSONRPCError(
182
+ JSONRPCError.INVALID_REQUEST,
183
+ "Response cannot have both result and error",
184
+ request_id=message.id
185
+ )
186
+
187
+ if not has_result and not has_error:
188
+ raise JSONRPCError(
189
+ JSONRPCError.INVALID_REQUEST,
190
+ "Response must have either result or error",
191
+ request_id=message.id
192
+ )
193
+
194
+ else:
195
+ raise JSONRPCError(
196
+ JSONRPCError.INVALID_REQUEST,
197
+ "Invalid message type",
198
+ request_id=data.get("id")
199
+ )
200
+
201
+ return message
202
+
203
+ @staticmethod
204
+ def create_request(method: str, params: Optional[Union[Dict[str, Any], list]] = None,
205
+ request_id: Union[str, int] = "1") -> Dict[str, Any]:
206
+ """Create a JSON-RPC 2.0 request."""
207
+ request = {
208
+ "jsonrpc": "2.0",
209
+ "method": method,
210
+ "id": request_id
211
+ }
212
+
213
+ if params is not None:
214
+ request["params"] = params
215
+
216
+ return request
217
+
218
+ @staticmethod
219
+ def create_notification(method: str, params: Optional[Union[Dict[str, Any], list]] = None) -> Dict[str, Any]:
220
+ """Create a JSON-RPC 2.0 notification."""
221
+ notification = {
222
+ "jsonrpc": "2.0",
223
+ "method": method
224
+ }
225
+
226
+ if params is not None:
227
+ notification["params"] = params
228
+
229
+ return notification
230
+
231
+ @staticmethod
232
+ def create_response(request_id: Union[str, int], result: Any) -> Dict[str, Any]:
233
+ """Create a JSON-RPC 2.0 success response."""
234
+ return {
235
+ "jsonrpc": "2.0",
236
+ "id": request_id,
237
+ "result": result
238
+ }
239
+
240
+ @staticmethod
241
+ def create_error_response(request_id: Union[str, int, None], error: JSONRPCError) -> Dict[str, Any]:
242
+ """Create a JSON-RPC 2.0 error response."""
243
+ return error.to_dict()
@@ -0,0 +1,201 @@
1
+ """
2
+ Stdio transport for MCP server.
3
+
4
+ Implements JSON-RPC 2.0 over stdin/stdout with proper hygiene:
5
+ - Never prints to stdout (only stderr for logging)
6
+ - One JSON message per line
7
+ - Proper error handling and cleanup
8
+ - Async I/O for non-blocking operation
9
+
10
+ Following mature MCP patterns: "stdio servers never print to stdout."
11
+ """
12
+
13
+ import asyncio
14
+ import sys
15
+ import logging
16
+ from typing import Any, Dict, AsyncIterator
17
+ from ..transport.base import Transport
18
+ from ..transport.jsonrpc import JSONRPCEnvelope, JSONRPCError, JSONRPCMessage
19
+
20
+ # Configure logging to stderr only (never stdout)
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class StdioTransport(Transport):
25
+ """
26
+ Stdio transport using JSON-RPC 2.0 over stdin/stdout.
27
+
28
+ Key characteristics:
29
+ - Reads JSON-RPC messages from stdin (one per line)
30
+ - Writes JSON-RPC responses to stdout (one per line)
31
+ - Logs only to stderr (never stdout)
32
+ - Non-blocking async I/O
33
+ - Graceful error handling
34
+ """
35
+
36
+ def __init__(self):
37
+ super().__init__("stdio")
38
+ self._stdin_reader: asyncio.StreamReader = None
39
+ self._stdout_writer: asyncio.StreamWriter = None
40
+ self._shutdown_event = asyncio.Event()
41
+
42
+ async def startup(self) -> None:
43
+ """Initialize stdin/stdout streams."""
44
+ await super().startup()
45
+
46
+ # Set up async stdin/stdout
47
+ loop = asyncio.get_event_loop()
48
+
49
+ # Create stdin reader
50
+ self._stdin_reader = asyncio.StreamReader()
51
+ stdin_protocol = asyncio.StreamReaderProtocol(self._stdin_reader)
52
+ await loop.connect_read_pipe(lambda: stdin_protocol, sys.stdin)
53
+
54
+ # Create stdout writer
55
+ stdout_transport, stdout_protocol = await loop.connect_write_pipe(
56
+ asyncio.streams.FlowControlMixin, sys.stdout
57
+ )
58
+ self._stdout_writer = asyncio.StreamWriter(stdout_transport, stdout_protocol, None, loop)
59
+
60
+ logger.info("Stdio transport initialized", file=sys.stderr)
61
+
62
+ async def shutdown(self) -> None:
63
+ """Clean up streams."""
64
+ await super().shutdown()
65
+
66
+ if self._stdout_writer:
67
+ self._stdout_writer.close()
68
+ await self._stdout_writer.wait_closed()
69
+
70
+ self._shutdown_event.set()
71
+ logger.info("Stdio transport shutdown complete", file=sys.stderr)
72
+
73
+ async def receive_messages(self) -> AsyncIterator[Dict[str, Any]]:
74
+ """
75
+ Read JSON-RPC messages from stdin.
76
+
77
+ Yields parsed and validated JSON-RPC messages.
78
+ """
79
+ if not self._stdin_reader:
80
+ raise RuntimeError("Transport not started")
81
+
82
+ logger.info("Starting to read messages from stdin", file=sys.stderr)
83
+
84
+ try:
85
+ while self._running and not self._shutdown_event.is_set():
86
+ try:
87
+ # Read one line from stdin
88
+ line = await asyncio.wait_for(
89
+ self._stdin_reader.readline(),
90
+ timeout=0.1 # Short timeout to check shutdown
91
+ )
92
+
93
+ if not line:
94
+ # EOF reached
95
+ logger.info("EOF reached on stdin", file=sys.stderr)
96
+ break
97
+
98
+ line_str = line.decode('utf-8').rstrip('\n\r')
99
+ if not line_str:
100
+ continue # Skip empty lines
101
+
102
+ logger.debug(f"Received message: {line_str[:100]}...", file=sys.stderr)
103
+
104
+ # Parse JSON-RPC message
105
+ try:
106
+ message = JSONRPCEnvelope.decode(line_str)
107
+
108
+ # Convert to dict for handler
109
+ message_dict = {
110
+ "jsonrpc": message.jsonrpc,
111
+ "id": message.id,
112
+ }
113
+
114
+ if message.method:
115
+ message_dict["method"] = message.method
116
+
117
+ if message.params is not None:
118
+ message_dict["params"] = message.params
119
+
120
+ if message.result is not None:
121
+ message_dict["result"] = message.result
122
+
123
+ if message.error is not None:
124
+ message_dict["error"] = message.error
125
+
126
+ yield message_dict
127
+
128
+ except JSONRPCError as e:
129
+ logger.error(f"JSON-RPC parse error: {e}", file=sys.stderr)
130
+
131
+ # Send error response if we can determine request ID
132
+ error_response = e.to_dict()
133
+ await self.send_message(error_response)
134
+
135
+ except asyncio.TimeoutError:
136
+ # Timeout is expected, just check if we should continue
137
+ continue
138
+
139
+ except Exception as e:
140
+ logger.error(f"Error reading from stdin: {e}", file=sys.stderr)
141
+ break
142
+
143
+ except Exception as e:
144
+ logger.error(f"Fatal error in message reception: {e}", file=sys.stderr)
145
+
146
+ finally:
147
+ logger.info("Stopped reading messages", file=sys.stderr)
148
+
149
+ async def send_message(self, message: Dict[str, Any]) -> None:
150
+ """
151
+ Send JSON-RPC message to stdout.
152
+
153
+ Encodes message as single line JSON and writes to stdout.
154
+ """
155
+ if not self._stdout_writer:
156
+ raise RuntimeError("Transport not started")
157
+
158
+ try:
159
+ # Encode to single-line JSON
160
+ json_line = JSONRPCEnvelope.encode(message)
161
+
162
+ logger.debug(f"Sending message: {json_line[:100]}...", file=sys.stderr)
163
+
164
+ # Write to stdout with newline
165
+ self._stdout_writer.write((json_line + '\n').encode('utf-8'))
166
+ await self._stdout_writer.drain()
167
+
168
+ except Exception as e:
169
+ logger.error(f"Error sending message: {e}", file=sys.stderr)
170
+ raise
171
+
172
+ async def send_notification(self, method: str, params: Any = None) -> None:
173
+ """Send a JSON-RPC notification."""
174
+ notification = JSONRPCEnvelope.create_notification(method, params)
175
+ await self.send_message(notification)
176
+
177
+ async def send_progress_notification(self, token: str, value: int, total: int, message: str = "") -> None:
178
+ """Send MCP progress notification."""
179
+ await self.send_notification("notifications/progress", {
180
+ "progressToken": token,
181
+ "progress": value,
182
+ "total": total,
183
+ "message": message
184
+ })
185
+
186
+ async def send_log_notification(self, level: str, message: str, data: Any = None) -> None:
187
+ """Send MCP log notification."""
188
+ log_params = {
189
+ "level": level,
190
+ "message": message
191
+ }
192
+
193
+ if data:
194
+ log_params["data"] = data
195
+
196
+ await self.send_notification("notifications/message", log_params)
197
+
198
+ def request_shutdown(self) -> None:
199
+ """Request graceful shutdown."""
200
+ logger.info("Shutdown requested", file=sys.stderr)
201
+ self._shutdown_event.set()