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.
- mcp_proxy/__init__.py +89 -0
- mcp_proxy/__main__.py +340 -0
- mcp_proxy/auth/__init__.py +8 -0
- mcp_proxy/auth/manager.py +908 -0
- mcp_proxy/config/__init__.py +8 -0
- mcp_proxy/config/manager.py +200 -0
- mcp_proxy/exceptions.py +186 -0
- mcp_proxy/http/__init__.py +9 -0
- mcp_proxy/http/authenticated_client.py +388 -0
- mcp_proxy/http/client.py +997 -0
- mcp_proxy/logging_config.py +71 -0
- mcp_proxy/models.py +259 -0
- mcp_proxy/protocols.py +122 -0
- mcp_proxy/proxy.py +586 -0
- mcp_proxy/stdio/__init__.py +31 -0
- mcp_proxy/stdio/interface.py +580 -0
- mcp_proxy/stdio/jsonrpc.py +371 -0
- mcp_proxy/translator/__init__.py +11 -0
- mcp_proxy/translator/translator.py +691 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/METADATA +167 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/RECORD +25 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/WHEEL +5 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_proxy_oauth_dcr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|