mcp-proxy-oauth-dcr 0.1.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.
@@ -0,0 +1,691 @@
1
+ """Protocol translator implementation for MCP Proxy.
2
+
3
+ This module provides translation between stdio JSON-RPC messages and HTTP MCP requests,
4
+ handling method mapping, request correlation, and protocol-specific differences with
5
+ comprehensive error logging and diagnostics.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from typing import Dict, Optional, Union
11
+ from datetime import datetime
12
+
13
+ from ..models import (
14
+ JsonRpcMessage,
15
+ JsonRpcError,
16
+ HttpMcpRequest,
17
+ HttpMcpResponse,
18
+ MessageCorrelation,
19
+ MessageStatus,
20
+ )
21
+ from ..stdio.jsonrpc import MessageIdGenerator, MessageCorrelationTracker
22
+ from ..exceptions import (
23
+ ProtocolError,
24
+ UnsupportedMethodError,
25
+ InvalidJsonRpcError,
26
+ MessageCorrelationError,
27
+ )
28
+ from ..logging_config import get_logger
29
+
30
+ logger = get_logger(__name__)
31
+
32
+
33
+ class ProtocolTranslatorImpl:
34
+ """Translates between stdio JSON-RPC and HTTP MCP protocols.
35
+
36
+ This translator handles:
37
+ - Converting JSON-RPC messages to HTTP POST requests
38
+ - Mapping MCP methods between protocols
39
+ - Maintaining request correlation across protocols
40
+ - Managing session state and headers
41
+
42
+ Attributes:
43
+ base_url: Base URL for the HTTP MCP server
44
+ session_id: Current MCP session ID
45
+ correlation_tracker: Tracks message correlations
46
+ id_generator: Generates unique HTTP request IDs
47
+ """
48
+
49
+ # MCP HTTP Streamable transport sends ALL requests to the same endpoint (base URL)
50
+ # The method name is included in the JSON-RPC body, not the URL path
51
+ # This is different from REST-style APIs where each method has its own endpoint
52
+ # Use empty string "" instead of "/" to avoid trailing slash issues
53
+ METHOD_ENDPOINT_MAP = {
54
+ "initialize": "",
55
+ "ping": "",
56
+ "tools/list": "",
57
+ "tools/call": "",
58
+ "resources/list": "",
59
+ "resources/read": "",
60
+ "prompts/list": "",
61
+ "prompts/get": "",
62
+ "completion/complete": "",
63
+ "logging/setLevel": "",
64
+ "notifications/initialized": "",
65
+ "notifications/cancelled": "",
66
+ "notifications/progress": "",
67
+ }
68
+
69
+ # HTTP method for each MCP method (all are POST for JSON-RPC over HTTP)
70
+ METHOD_HTTP_METHOD_MAP = {
71
+ "initialize": "POST",
72
+ "ping": "POST",
73
+ "tools/list": "POST",
74
+ "tools/call": "POST",
75
+ "resources/list": "POST",
76
+ "resources/read": "POST",
77
+ "prompts/list": "POST",
78
+ "prompts/get": "POST",
79
+ "completion/complete": "POST",
80
+ "logging/setLevel": "POST",
81
+ "notifications/initialized": "POST",
82
+ "notifications/cancelled": "POST",
83
+ "notifications/progress": "POST",
84
+ }
85
+
86
+ def __init__(self, base_url: str, session_id: Optional[str] = None):
87
+ """Initialize the protocol translator.
88
+
89
+ Args:
90
+ base_url: Base URL for the HTTP MCP server
91
+ session_id: Optional initial session ID
92
+ """
93
+ self._base_url = base_url.rstrip("/")
94
+ self._session_id = session_id
95
+ self._correlation_tracker = MessageCorrelationTracker()
96
+ self._id_generator = MessageIdGenerator(prefix="http")
97
+
98
+ logger.debug(
99
+ "ProtocolTranslatorImpl initialized with base_url=%s, session_id=%s",
100
+ self._base_url, self._session_id
101
+ )
102
+
103
+ @property
104
+ def session_id(self) -> Optional[str]:
105
+ """Get the current session ID."""
106
+ return self._session_id
107
+
108
+ @session_id.setter
109
+ def session_id(self, value: Optional[str]) -> None:
110
+ """Set the session ID."""
111
+ self._session_id = value
112
+ logger.debug("Session ID updated to: %s", value)
113
+
114
+ @property
115
+ def correlation_tracker(self) -> MessageCorrelationTracker:
116
+ """Get the correlation tracker."""
117
+ return self._correlation_tracker
118
+
119
+ def _extract_json_from_sse(self, sse_body: str) -> str:
120
+ """Extract JSON data from SSE format response.
121
+
122
+ SSE format looks like:
123
+ event: message
124
+ data: {"jsonrpc":"2.0",...}
125
+
126
+ This method extracts the JSON from the "data:" line(s).
127
+
128
+ Args:
129
+ sse_body: Raw SSE response body
130
+
131
+ Returns:
132
+ Extracted JSON string, or empty string if no data found
133
+ """
134
+ if not sse_body:
135
+ return ""
136
+
137
+ data_lines = []
138
+ for line in sse_body.split("\n"):
139
+ line = line.strip()
140
+ if line.startswith("data:"):
141
+ # Extract content after "data:"
142
+ data_content = line[5:].strip()
143
+ if data_content:
144
+ data_lines.append(data_content)
145
+
146
+ # Join multiple data lines (SSE spec allows multi-line data)
147
+ return "\n".join(data_lines) if data_lines else ""
148
+
149
+ def _get_endpoint_for_method(self, method: str) -> str:
150
+ """Get the HTTP endpoint for an MCP method.
151
+
152
+ For MCP HTTP Streamable transport, all methods go to the same endpoint (base URL).
153
+ The method name is included in the JSON-RPC body.
154
+
155
+ Args:
156
+ method: MCP method name
157
+
158
+ Returns:
159
+ HTTP endpoint path (always "" for MCP HTTP Streamable to avoid trailing slash)
160
+ """
161
+ # MCP HTTP Streamable sends all requests to the base URL
162
+ # Return "" for all methods - the method is in the JSON-RPC body
163
+ return self.METHOD_ENDPOINT_MAP.get(method, "")
164
+
165
+ def _get_http_method_for_mcp_method(self, method: str) -> str:
166
+ """Get the HTTP method for an MCP method.
167
+
168
+ For MCP HTTP Streamable transport, all requests use POST.
169
+
170
+ Args:
171
+ method: MCP method name
172
+
173
+ Returns:
174
+ HTTP method (always POST for MCP)
175
+ """
176
+ # MCP HTTP Streamable uses POST for all JSON-RPC requests
177
+ return self.METHOD_HTTP_METHOD_MAP.get(method, "POST")
178
+
179
+ def _build_http_body(self, message: JsonRpcMessage) -> Optional[str]:
180
+ """Build HTTP request body from JSON-RPC message.
181
+
182
+ For POST requests, the body contains the JSON-RPC message parameters.
183
+
184
+ Args:
185
+ message: JSON-RPC message
186
+
187
+ Returns:
188
+ JSON string for request body, or None for GET requests
189
+ """
190
+ if not message.is_request():
191
+ raise InvalidJsonRpcError(
192
+ "Cannot build HTTP body from non-request message",
193
+ details={"message": message.model_dump()}
194
+ )
195
+
196
+ # Build request body with params
197
+ body_data = {
198
+ "jsonrpc": "2.0",
199
+ "id": message.id,
200
+ "method": message.method,
201
+ }
202
+
203
+ # Include params if present
204
+ if message.params is not None:
205
+ body_data["params"] = message.params
206
+
207
+ return json.dumps(body_data)
208
+
209
+ async def translate_stdio_to_http(
210
+ self,
211
+ message: JsonRpcMessage
212
+ ) -> HttpMcpRequest:
213
+ """Translate stdio JSON-RPC message to HTTP MCP request.
214
+
215
+ This method:
216
+ 1. Validates the JSON-RPC message is a request
217
+ 2. Maps the MCP method to an HTTP endpoint
218
+ 3. Generates a unique HTTP request ID
219
+ 4. Creates correlation between stdio and HTTP IDs
220
+ 5. Builds the HTTP request with appropriate headers and body
221
+
222
+ Args:
223
+ message: JSON-RPC message from stdio
224
+
225
+ Returns:
226
+ HTTP MCP request ready to send
227
+
228
+ Raises:
229
+ InvalidJsonRpcError: If message is not a valid request
230
+ UnsupportedMethodError: If method is not supported
231
+ MessageCorrelationError: If correlation already exists
232
+ """
233
+ # Validate this is a request message
234
+ if not message.is_request():
235
+ raise InvalidJsonRpcError(
236
+ "Can only translate request messages to HTTP",
237
+ details={"message": message.model_dump()}
238
+ )
239
+
240
+ method = message.method
241
+ if not method:
242
+ raise InvalidJsonRpcError(
243
+ "Request message must have a method",
244
+ details={"message": message.model_dump()}
245
+ )
246
+
247
+ logger.debug(
248
+ "Translating stdio request to HTTP: method=%s, id=%s",
249
+ method, message.id
250
+ )
251
+
252
+ # Get endpoint and HTTP method for this MCP method
253
+ endpoint = self._get_endpoint_for_method(method)
254
+ http_method = self._get_http_method_for_mcp_method(method)
255
+
256
+ # Generate unique HTTP request ID
257
+ http_request_id = self._id_generator.generate()
258
+
259
+ # Create correlation if this is not a notification
260
+ if message.id is not None:
261
+ self._correlation_tracker.add_correlation(
262
+ stdio_request_id=message.id,
263
+ http_request_id=http_request_id,
264
+ method=method
265
+ )
266
+ logger.debug(
267
+ "Created correlation: stdio_id=%s -> http_id=%s",
268
+ message.id, http_request_id
269
+ )
270
+
271
+ # Build request body
272
+ body = self._build_http_body(message)
273
+
274
+ # Build headers - MCP HTTP Streamable requires Accept header for both JSON and SSE
275
+ headers = {
276
+ "Content-Type": "application/json",
277
+ "Accept": "application/json, text/event-stream",
278
+ }
279
+
280
+ # Add session ID header if available
281
+ if self._session_id:
282
+ headers["Mcp-Session-Id"] = self._session_id
283
+
284
+ # Create HTTP request
285
+ http_request = HttpMcpRequest(
286
+ method=http_method,
287
+ url=f"{self._base_url}{endpoint}",
288
+ headers=headers,
289
+ body=body,
290
+ session_id=self._session_id,
291
+ )
292
+
293
+ logger.info(
294
+ "Translated stdio request to HTTP: method=%s, endpoint=%s, "
295
+ "stdio_id=%s, http_id=%s",
296
+ method, endpoint, message.id, http_request_id
297
+ )
298
+
299
+ return http_request
300
+
301
+ async def translate_http_to_stdio(
302
+ self,
303
+ response: HttpMcpResponse,
304
+ stdio_request_id: Optional[Union[str, int]] = None
305
+ ) -> JsonRpcMessage:
306
+ """Translate HTTP MCP response to stdio JSON-RPC message.
307
+
308
+ This method:
309
+ 1. Parses the HTTP response body as JSON (or extracts from SSE format)
310
+ 2. Handles successful responses by extracting the result
311
+ 3. Handles error responses by translating to JSON-RPC errors
312
+ 4. Maintains correlation with the original stdio request
313
+
314
+ Args:
315
+ response: HTTP MCP response
316
+ stdio_request_id: Optional stdio request ID for correlation lookup
317
+
318
+ Returns:
319
+ JSON-RPC message for stdio
320
+
321
+ Raises:
322
+ ProtocolError: If response cannot be translated
323
+ MessageCorrelationError: If correlation cannot be found
324
+ """
325
+ logger.debug(
326
+ "Translating HTTP response to stdio: status=%s, content_type=%s, stdio_id=%s",
327
+ response.status, response.content_type, stdio_request_id
328
+ )
329
+
330
+ # If stdio_request_id not provided, we can't correlate
331
+ # This is acceptable for notifications or streaming events
332
+ request_id = stdio_request_id
333
+
334
+ # Handle HTTP errors by translating to JSON-RPC errors
335
+ if not response.is_success():
336
+ return self._translate_http_error_to_stdio(response, request_id)
337
+
338
+ # Extract body data - handle SSE format if content type is text/event-stream
339
+ body_to_parse = response.body
340
+ if response.content_type and "text/event-stream" in response.content_type:
341
+ # SSE format: extract JSON from "data:" lines
342
+ body_to_parse = self._extract_json_from_sse(response.body)
343
+
344
+ # Parse response body
345
+ try:
346
+ body_data = json.loads(body_to_parse) if body_to_parse else {}
347
+ except json.JSONDecodeError as e:
348
+ logger.error("Failed to parse HTTP response body: %s", e)
349
+ # Return JSON-RPC parse error
350
+ return JsonRpcMessage(
351
+ jsonrpc="2.0",
352
+ id=request_id,
353
+ error=JsonRpcError(
354
+ code=-32700,
355
+ message="Parse error",
356
+ data={"details": f"Invalid JSON in HTTP response: {str(e)}"}
357
+ )
358
+ )
359
+
360
+ # Check if the response body is already a JSON-RPC message
361
+ if "jsonrpc" in body_data:
362
+ # Response is already in JSON-RPC format, use it directly
363
+ # but ensure the ID matches our correlation
364
+ if request_id is not None:
365
+ body_data["id"] = request_id
366
+
367
+ try:
368
+ stdio_message = JsonRpcMessage.model_validate(body_data)
369
+ logger.info(
370
+ "Translated HTTP response to stdio: status=%s, stdio_id=%s",
371
+ response.status, request_id
372
+ )
373
+ return stdio_message
374
+ except Exception as e:
375
+ logger.error("Failed to validate JSON-RPC message: %s", e)
376
+ return JsonRpcMessage(
377
+ jsonrpc="2.0",
378
+ id=request_id,
379
+ error=JsonRpcError(
380
+ code=-32603,
381
+ message="Internal error",
382
+ data={"details": f"Invalid JSON-RPC format: {str(e)}"}
383
+ )
384
+ )
385
+
386
+ # Response is not in JSON-RPC format, wrap it as a result
387
+ stdio_message = JsonRpcMessage(
388
+ jsonrpc="2.0",
389
+ id=request_id,
390
+ result=body_data
391
+ )
392
+
393
+ logger.info(
394
+ "Translated HTTP response to stdio: status=%s, stdio_id=%s",
395
+ response.status, request_id
396
+ )
397
+
398
+ return stdio_message
399
+
400
+ def _translate_http_error_to_stdio(
401
+ self,
402
+ response: HttpMcpResponse,
403
+ request_id: Optional[Union[str, int]] = None
404
+ ) -> JsonRpcMessage:
405
+ """Translate HTTP error response to JSON-RPC error message.
406
+
407
+ Maps HTTP status codes to appropriate JSON-RPC error codes:
408
+ - 400 Bad Request -> -32602 Invalid params
409
+ - 401 Unauthorized -> -32000 Server error (authentication)
410
+ - 404 Not Found -> -32601 Method not found
411
+ - 500+ Server errors -> -32603 Internal error
412
+ - Other errors -> -32603 Internal error
413
+
414
+ Args:
415
+ response: HTTP error response
416
+ request_id: Optional stdio request ID
417
+
418
+ Returns:
419
+ JSON-RPC error message
420
+ """
421
+ logger.debug(
422
+ "Translating HTTP error to stdio: status=%s, stdio_id=%s",
423
+ response.status, request_id
424
+ )
425
+
426
+ # Try to parse error details from response body
427
+ error_data = None
428
+ error_message = f"HTTP {response.status}"
429
+
430
+ try:
431
+ if response.body:
432
+ body_data = json.loads(response.body)
433
+
434
+ # Check if body contains a JSON-RPC error
435
+ if "error" in body_data:
436
+ error_obj = body_data["error"]
437
+ if isinstance(error_obj, dict):
438
+ # Use the JSON-RPC error directly
439
+ return JsonRpcMessage(
440
+ jsonrpc="2.0",
441
+ id=request_id,
442
+ error=JsonRpcError(
443
+ code=error_obj.get("code", -32603),
444
+ message=error_obj.get("message", error_message),
445
+ data=error_obj.get("data")
446
+ )
447
+ )
448
+
449
+ # Extract error message from common formats
450
+ if "message" in body_data:
451
+ error_message = body_data["message"]
452
+ elif "error" in body_data and isinstance(body_data["error"], str):
453
+ error_message = body_data["error"]
454
+
455
+ # Store full body as error data
456
+ error_data = body_data
457
+ except json.JSONDecodeError:
458
+ # If body is not JSON, use it as error message
459
+ if response.body:
460
+ error_message = response.body[:200] # Limit length
461
+ error_data = {"raw_response": response.body[:500]}
462
+
463
+ # Map HTTP status to JSON-RPC error code
464
+ if response.status == 400:
465
+ error_code = -32602 # Invalid params
466
+ elif response.status == 401:
467
+ error_code = -32000 # Server error (authentication)
468
+ error_message = f"Authentication failed: {error_message}"
469
+ elif response.status == 404:
470
+ error_code = -32601 # Method not found
471
+ error_message = f"Method not found: {error_message}"
472
+ elif response.status >= 500:
473
+ error_code = -32603 # Internal error
474
+ error_message = f"Server error: {error_message}"
475
+ else:
476
+ error_code = -32603 # Internal error
477
+ error_message = f"HTTP error {response.status}: {error_message}"
478
+
479
+ # Create JSON-RPC error message
480
+ stdio_message = JsonRpcMessage(
481
+ jsonrpc="2.0",
482
+ id=request_id,
483
+ error=JsonRpcError(
484
+ code=error_code,
485
+ message=error_message,
486
+ data=error_data
487
+ )
488
+ )
489
+
490
+ logger.info(
491
+ "Translated HTTP error to stdio: status=%s, code=%s, stdio_id=%s",
492
+ response.status, error_code, request_id
493
+ )
494
+
495
+ return stdio_message
496
+
497
+ async def translate_sse_event_to_stdio(
498
+ self,
499
+ event_data: str,
500
+ event_type: Optional[str] = None
501
+ ) -> Optional[JsonRpcMessage]:
502
+ """Translate Server-Sent Event to stdio JSON-RPC message.
503
+
504
+ SSE events from the HTTP MCP server are translated to JSON-RPC
505
+ notifications or responses depending on the event type.
506
+
507
+ Args:
508
+ event_data: SSE event data (JSON string)
509
+ event_type: Optional SSE event type
510
+
511
+ Returns:
512
+ JSON-RPC message for stdio, or None if event should be ignored
513
+
514
+ Raises:
515
+ ProtocolError: If event cannot be translated
516
+ """
517
+ logger.debug(
518
+ "Translating SSE event to stdio: type=%s, data_length=%s",
519
+ event_type, len(event_data) if event_data else 0
520
+ )
521
+
522
+ # Parse event data
523
+ try:
524
+ event_obj = json.loads(event_data) if event_data else {}
525
+ except json.JSONDecodeError as e:
526
+ logger.error("Failed to parse SSE event data: %s", e)
527
+ raise ProtocolError(
528
+ "Invalid JSON in SSE event",
529
+ details={"event_type": event_type, "error": str(e)}
530
+ )
531
+
532
+ # Check if event is already a JSON-RPC message
533
+ if "jsonrpc" in event_obj:
534
+ try:
535
+ stdio_message = JsonRpcMessage.model_validate(event_obj)
536
+ logger.info(
537
+ "Translated SSE event to stdio: type=%s, method=%s",
538
+ event_type, stdio_message.method
539
+ )
540
+ return stdio_message
541
+ except Exception as e:
542
+ logger.error("Failed to validate JSON-RPC message from SSE: %s", e)
543
+ raise ProtocolError(
544
+ "Invalid JSON-RPC format in SSE event",
545
+ details={"event_type": event_type, "error": str(e)}
546
+ )
547
+
548
+ # Handle different event types
549
+ if event_type == "message":
550
+ # Regular message event - wrap as notification
551
+ stdio_message = JsonRpcMessage(
552
+ jsonrpc="2.0",
553
+ method="notifications/message",
554
+ params=event_obj
555
+ )
556
+ logger.info("Translated SSE message event to stdio notification")
557
+ return stdio_message
558
+
559
+ elif event_type == "error":
560
+ # Error event - create error notification
561
+ error_message = event_obj.get("message", "Unknown error")
562
+ stdio_message = JsonRpcMessage(
563
+ jsonrpc="2.0",
564
+ method="notifications/error",
565
+ params={
566
+ "error": error_message,
567
+ "details": event_obj
568
+ }
569
+ )
570
+ logger.info("Translated SSE error event to stdio notification")
571
+ return stdio_message
572
+
573
+ elif event_type in ["ping", "heartbeat"]:
574
+ # Heartbeat events can be ignored or logged
575
+ logger.debug("Ignoring SSE heartbeat event")
576
+ return None
577
+
578
+ else:
579
+ # Unknown event type - wrap as generic notification
580
+ stdio_message = JsonRpcMessage(
581
+ jsonrpc="2.0",
582
+ method="notifications/event",
583
+ params={
584
+ "type": event_type,
585
+ "data": event_obj
586
+ }
587
+ )
588
+ logger.info(
589
+ "Translated SSE event to stdio notification: type=%s",
590
+ event_type
591
+ )
592
+ return stdio_message
593
+
594
+ def correlate_messages(
595
+ self,
596
+ request_id: Union[str, int],
597
+ response_id: str
598
+ ) -> None:
599
+ """Correlate request and response messages.
600
+
601
+ This method is used to manually correlate messages when needed.
602
+ In most cases, correlation is handled automatically during translation.
603
+
604
+ Args:
605
+ request_id: Stdio request ID
606
+ response_id: HTTP response ID
607
+
608
+ Raises:
609
+ MessageCorrelationError: If correlation fails
610
+ """
611
+ # Check if correlation already exists
612
+ correlation = self._correlation_tracker.get_correlation(request_id)
613
+ if correlation:
614
+ logger.warning(
615
+ "Correlation already exists for request_id=%s, updating",
616
+ request_id
617
+ )
618
+ # Update the HTTP request ID
619
+ correlation.http_request_id = response_id
620
+ else:
621
+ # Create new correlation
622
+ self._correlation_tracker.add_correlation(
623
+ stdio_request_id=request_id,
624
+ http_request_id=response_id,
625
+ method="unknown"
626
+ )
627
+
628
+ logger.debug(
629
+ "Manually correlated messages: stdio_id=%s -> http_id=%s",
630
+ request_id, response_id
631
+ )
632
+
633
+ def get_correlation(
634
+ self,
635
+ stdio_request_id: Union[str, int]
636
+ ) -> Optional[MessageCorrelation]:
637
+ """Get correlation for a stdio request ID.
638
+
639
+ Args:
640
+ stdio_request_id: Stdio request ID
641
+
642
+ Returns:
643
+ MessageCorrelation if found, None otherwise
644
+ """
645
+ return self._correlation_tracker.get_correlation(stdio_request_id)
646
+
647
+ def mark_correlation_completed(
648
+ self,
649
+ stdio_request_id: Union[str, int]
650
+ ) -> None:
651
+ """Mark a correlation as completed.
652
+
653
+ Args:
654
+ stdio_request_id: Stdio request ID
655
+
656
+ Raises:
657
+ MessageCorrelationError: If correlation not found
658
+ """
659
+ self._correlation_tracker.mark_completed(stdio_request_id)
660
+ logger.debug("Marked correlation completed: stdio_id=%s", stdio_request_id)
661
+
662
+ def mark_correlation_failed(
663
+ self,
664
+ stdio_request_id: Union[str, int]
665
+ ) -> None:
666
+ """Mark a correlation as failed.
667
+
668
+ Args:
669
+ stdio_request_id: Stdio request ID
670
+
671
+ Raises:
672
+ MessageCorrelationError: If correlation not found
673
+ """
674
+ self._correlation_tracker.mark_failed(stdio_request_id)
675
+ logger.debug("Marked correlation failed: stdio_id=%s", stdio_request_id)
676
+
677
+ def clear_correlations(self) -> None:
678
+ """Clear all correlations.
679
+
680
+ Useful for cleanup and testing.
681
+ """
682
+ self._correlation_tracker.clear()
683
+ logger.debug("Cleared all correlations")
684
+
685
+ def get_pending_correlations(self) -> list[MessageCorrelation]:
686
+ """Get all pending correlations.
687
+
688
+ Returns:
689
+ List of pending MessageCorrelation objects
690
+ """
691
+ return self._correlation_tracker.get_pending_correlations()