|
@@ -1,3 +1,4 @@
|
|
|
|
|
+import hashlib
|
|
|
import json
|
|
import json
|
|
|
import logging
|
|
import logging
|
|
|
import threading
|
|
import threading
|
|
@@ -28,7 +29,31 @@ class PublicMcpHttpHandler:
|
|
|
# unknown names fall back to the bare IP bucket, preventing bucket explosion
|
|
# unknown names fall back to the bare IP bucket, preventing bucket explosion
|
|
|
self._known_tools = frozenset(gateway_app.registered_tool_names())
|
|
self._known_tools = frozenset(gateway_app.registered_tool_names())
|
|
|
|
|
|
|
|
- def _check_rate_limit(self, rate_key, method, request_id=None, *, log_identity=None):
|
|
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _build_trace_request_id(headers):
|
|
|
|
|
+ return 'rq_http_{0}'.format(uuid.uuid4().hex[:16])
|
|
|
|
|
+
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _client_request_id(headers):
|
|
|
|
|
+ incoming = ''
|
|
|
|
|
+ for key, value in (headers or {}).items():
|
|
|
|
|
+ if str(key).lower() == 'x-request-id':
|
|
|
|
|
+ incoming = str(value or '').strip()
|
|
|
|
|
+ break
|
|
|
|
|
+ if not incoming:
|
|
|
|
|
+ return ''
|
|
|
|
|
+ return hashlib.sha256(incoming.encode('utf-8')).hexdigest()[:16]
|
|
|
|
|
+
|
|
|
|
|
+ def _check_rate_limit(
|
|
|
|
|
+ self,
|
|
|
|
|
+ rate_key,
|
|
|
|
|
+ method,
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ jsonrpc_id=None,
|
|
|
|
|
+ tool_name='',
|
|
|
|
|
+ *,
|
|
|
|
|
+ log_identity=None
|
|
|
|
|
+ ):
|
|
|
"""Returns an error response if rate limit exceeded, else None.
|
|
"""Returns an error response if rate limit exceeded, else None.
|
|
|
|
|
|
|
|
rate_key — the bucket key used by the limiter (session-based for tools/call,
|
|
rate_key — the bucket key used by the limiter (session-based for tools/call,
|
|
@@ -37,13 +62,35 @@ class PublicMcpHttpHandler:
|
|
|
"""
|
|
"""
|
|
|
if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
|
|
if self.rate_limiter and rate_key and not self.rate_limiter.is_allowed(rate_key):
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
- f"[RATE_LIMIT] rate limit exceeded: identity={log_identity or rate_key}, method={method}"
|
|
|
|
|
|
|
+ "MCP public rate limit exceeded",
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': jsonrpc_id,
|
|
|
|
|
+ 'protocol_method': method,
|
|
|
|
|
+ 'tool_code': tool_name,
|
|
|
|
|
+ 'client_identity': log_identity or rate_key,
|
|
|
|
|
+ 'protocol_code': -32029,
|
|
|
|
|
+ 'diagnostic_reason': 'RATE_LIMIT_EXCEEDED',
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return McpProtocolHandler._error_response(
|
|
|
|
|
+ jsonrpc_id,
|
|
|
|
|
+ -32029,
|
|
|
|
|
+ 'Rate limit exceeded. Please try again later.',
|
|
|
|
|
+ trace_request_id,
|
|
|
)
|
|
)
|
|
|
- return McpProtocolHandler._error_response(request_id, -32000, 'Rate limit exceeded. Please try again later.')
|
|
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
- def handle_json_rpc(self, headers, message, client_ip=''):
|
|
|
|
|
|
|
+ def handle_json_rpc(
|
|
|
|
|
+ self,
|
|
|
|
|
+ headers,
|
|
|
|
|
+ message,
|
|
|
|
|
+ client_ip='',
|
|
|
|
|
+ trace_request_id='',
|
|
|
|
|
+ ):
|
|
|
request_id = message.get('id') if isinstance(message, dict) else None
|
|
request_id = message.get('id') if isinstance(message, dict) else None
|
|
|
|
|
+ trace_request_id = str(trace_request_id or '').strip() \
|
|
|
|
|
+ or self._build_trace_request_id(headers)
|
|
|
method = str((message or {}).get('method') or '').strip()
|
|
method = str((message or {}).get('method') or '').strip()
|
|
|
message_params = (message or {}).get('params') or {}
|
|
message_params = (message or {}).get('params') or {}
|
|
|
tool_name = str(message_params.get('name') or '').strip() \
|
|
tool_name = str(message_params.get('name') or '').strip() \
|
|
@@ -65,16 +112,38 @@ class PublicMcpHttpHandler:
|
|
|
# Keep list traffic in a dedicated IP-based bucket so it does not
|
|
# Keep list traffic in a dedicated IP-based bucket so it does not
|
|
|
# compete with the per-session tools/call quota.
|
|
# compete with the per-session tools/call quota.
|
|
|
blocked = self._check_rate_limit(
|
|
blocked = self._check_rate_limit(
|
|
|
- 'list:{0}'.format(client_ip), method, request_id,
|
|
|
|
|
|
|
+ 'list:{0}'.format(client_ip),
|
|
|
|
|
+ method,
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ request_id,
|
|
|
log_identity=client_ip,
|
|
log_identity=client_ip,
|
|
|
)
|
|
)
|
|
|
if blocked:
|
|
if blocked:
|
|
|
return blocked
|
|
return blocked
|
|
|
context = self.context_parser.parse(headers or {})
|
|
context = self.context_parser.parse(headers or {})
|
|
|
if not context.has_session():
|
|
if not context.has_session():
|
|
|
- raise RuntimeError(DEVICE_INVALID_MESSAGE)
|
|
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ 'MCP public device session unavailable',
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': request_id,
|
|
|
|
|
+ 'protocol_method': method,
|
|
|
|
|
+ 'tool_code': '',
|
|
|
|
|
+ 'protocol_code': -32001,
|
|
|
|
|
+ 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return McpProtocolHandler._error_response(
|
|
|
|
|
+ request_id,
|
|
|
|
|
+ -32001,
|
|
|
|
|
+ DEVICE_INVALID_MESSAGE,
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ )
|
|
|
tools = []
|
|
tools = []
|
|
|
- for tool in self.gateway_app.list_tools(context.gateway_session_id):
|
|
|
|
|
|
|
+ for tool in self.gateway_app.list_tools(
|
|
|
|
|
+ context.gateway_session_id,
|
|
|
|
|
+ request_id=trace_request_id,
|
|
|
|
|
+ ):
|
|
|
normalized = dict(tool)
|
|
normalized = dict(tool)
|
|
|
if 'input_schema' in normalized:
|
|
if 'input_schema' in normalized:
|
|
|
normalized['inputSchema'] = normalized.pop('input_schema')
|
|
normalized['inputSchema'] = normalized.pop('input_schema')
|
|
@@ -86,12 +155,35 @@ class PublicMcpHttpHandler:
|
|
|
# Parse context first — tools/call always requires a valid session
|
|
# Parse context first — tools/call always requires a valid session
|
|
|
context = self.context_parser.parse(headers or {})
|
|
context = self.context_parser.parse(headers or {})
|
|
|
if not context.has_session():
|
|
if not context.has_session():
|
|
|
- raise RuntimeError(DEVICE_INVALID_MESSAGE)
|
|
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ 'MCP public device session unavailable',
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': request_id,
|
|
|
|
|
+ 'protocol_method': method,
|
|
|
|
|
+ 'tool_code': tool_name,
|
|
|
|
|
+ 'protocol_code': -32001,
|
|
|
|
|
+ 'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return McpProtocolHandler._error_response(
|
|
|
|
|
+ request_id,
|
|
|
|
|
+ -32001,
|
|
|
|
|
+ DEVICE_INVALID_MESSAGE,
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ )
|
|
|
# Session-based rate limiting: each employee gets an independent quota per tool.
|
|
# Session-based rate limiting: each employee gets an independent quota per tool.
|
|
|
# Unknown tool names fall back to the bare session bucket to prevent key explosion.
|
|
# Unknown tool names fall back to the bare session bucket to prevent key explosion.
|
|
|
session_id = context.gateway_session_id
|
|
session_id = context.gateway_session_id
|
|
|
rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
|
|
rate_key = '{0}:{1}'.format(session_id, tool_name) if tool_name in self._known_tools else session_id
|
|
|
- blocked = self._check_rate_limit(rate_key, method, request_id, log_identity=client_ip)
|
|
|
|
|
|
|
+ blocked = self._check_rate_limit(
|
|
|
|
|
+ rate_key,
|
|
|
|
|
+ method,
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ request_id,
|
|
|
|
|
+ tool_name,
|
|
|
|
|
+ log_identity=client_ip,
|
|
|
|
|
+ )
|
|
|
if blocked:
|
|
if blocked:
|
|
|
return blocked
|
|
return blocked
|
|
|
params = message_params
|
|
params = message_params
|
|
@@ -99,7 +191,7 @@ class PublicMcpHttpHandler:
|
|
|
session_id,
|
|
session_id,
|
|
|
params.get('name'),
|
|
params.get('name'),
|
|
|
params.get('arguments') or {},
|
|
params.get('arguments') or {},
|
|
|
- request_id='rq_http_{0}_{1}'.format(request_id, uuid.uuid4().hex[:16]),
|
|
|
|
|
|
|
+ request_id=trace_request_id,
|
|
|
client_ip=client_ip,
|
|
client_ip=client_ip,
|
|
|
)
|
|
)
|
|
|
return McpProtocolHandler._tool_call_response(
|
|
return McpProtocolHandler._tool_call_response(
|
|
@@ -107,15 +199,55 @@ class PublicMcpHttpHandler:
|
|
|
tool_name,
|
|
tool_name,
|
|
|
result,
|
|
result,
|
|
|
)
|
|
)
|
|
|
- return McpProtocolHandler._error_response(request_id, -32601, 'Method not found: {0}'.format(method))
|
|
|
|
|
|
|
+ return McpProtocolHandler._error_response(
|
|
|
|
|
+ request_id,
|
|
|
|
|
+ -32601,
|
|
|
|
|
+ 'Method not found: {0}'.format(method),
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ )
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
if method == 'tools/call':
|
|
if method == 'tools/call':
|
|
|
|
|
+ diagnostic_reason = (
|
|
|
|
|
+ 'PARAM_VALIDATION_FAILED'
|
|
|
|
|
+ if isinstance(exc, ValueError)
|
|
|
|
|
+ else 'UNEXPECTED_EXCEPTION'
|
|
|
|
|
+ )
|
|
|
|
|
+ logger.error(
|
|
|
|
|
+ 'MCP public tool request failed',
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': request_id,
|
|
|
|
|
+ 'protocol_method': method,
|
|
|
|
|
+ 'tool_code': tool_name,
|
|
|
|
|
+ 'response_code': 'MCP_9001',
|
|
|
|
|
+ 'diagnostic_reason': diagnostic_reason,
|
|
|
|
|
+ 'exception_class': exc.__class__.__name__,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
return McpProtocolHandler._tool_exception_response(
|
|
return McpProtocolHandler._tool_exception_response(
|
|
|
request_id,
|
|
request_id,
|
|
|
tool_name,
|
|
tool_name,
|
|
|
exc,
|
|
exc,
|
|
|
|
|
+ trace_request_id,
|
|
|
)
|
|
)
|
|
|
- return McpProtocolHandler._error_response(request_id, -32000, str(exc))
|
|
|
|
|
|
|
+ logger.error(
|
|
|
|
|
+ "MCP public request failed",
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': request_id,
|
|
|
|
|
+ 'protocol_method': method,
|
|
|
|
|
+ 'tool_code': tool_name,
|
|
|
|
|
+ 'protocol_code': -32000,
|
|
|
|
|
+ 'diagnostic_reason': 'UNEXPECTED_EXCEPTION',
|
|
|
|
|
+ 'exception_class': exc.__class__.__name__,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ return McpProtocolHandler._error_response(
|
|
|
|
|
+ request_id,
|
|
|
|
|
+ -32000,
|
|
|
|
|
+ 'Gateway request failed. Please try again later.',
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_http_handler(gateway_app, rate_limiter=None):
|
|
def create_http_handler(gateway_app, rate_limiter=None):
|
|
@@ -134,7 +266,12 @@ def create_http_handler(gateway_app, rate_limiter=None):
|
|
|
self.end_headers()
|
|
self.end_headers()
|
|
|
|
|
|
|
|
def do_POST(self):
|
|
def do_POST(self):
|
|
|
- client_ip = extract_client_ip(dict(self.headers.items()), self.client_address)
|
|
|
|
|
|
|
+ request_headers = dict(self.headers.items())
|
|
|
|
|
+ client_ip = extract_client_ip(request_headers, self.client_address)
|
|
|
|
|
+ trace_request_id = rpc_handler._build_trace_request_id(request_headers)
|
|
|
|
|
+ client_request_id_hash = rpc_handler._client_request_id(
|
|
|
|
|
+ request_headers
|
|
|
|
|
+ )
|
|
|
length = int(self.headers.get('Content-Length') or '0')
|
|
length = int(self.headers.get('Content-Length') or '0')
|
|
|
if self.path != '/mcp':
|
|
if self.path != '/mcp':
|
|
|
logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
|
|
logger.warning(f"[HTTP] 404: ip={client_ip}, path={self.path}")
|
|
@@ -147,13 +284,44 @@ def create_http_handler(gateway_app, rate_limiter=None):
|
|
|
try:
|
|
try:
|
|
|
message = json.loads(body)
|
|
message = json.loads(body)
|
|
|
except json.JSONDecodeError as exc:
|
|
except json.JSONDecodeError as exc:
|
|
|
- logger.warning(f"[HTTP] invalid JSON: ip={client_ip}, error={exc}")
|
|
|
|
|
- self._write_json(McpProtocolHandler._error_response(None, -32700, 'Parse error'))
|
|
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ 'MCP public invalid JSON',
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': None,
|
|
|
|
|
+ 'protocol_method': '',
|
|
|
|
|
+ 'tool_code': '',
|
|
|
|
|
+ 'protocol_code': -32700,
|
|
|
|
|
+ 'diagnostic_reason': 'PARAM_VALIDATION_FAILED',
|
|
|
|
|
+ 'exception_class': exc.__class__.__name__,
|
|
|
|
|
+ 'client_identity': client_ip,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ self._write_json(McpProtocolHandler._error_response(
|
|
|
|
|
+ None,
|
|
|
|
|
+ -32700,
|
|
|
|
|
+ 'Parse error',
|
|
|
|
|
+ trace_request_id,
|
|
|
|
|
+ ))
|
|
|
return
|
|
return
|
|
|
method = message.get('method', '')
|
|
method = message.get('method', '')
|
|
|
request_id = message.get('id') if message.get('id') is not None else ''
|
|
request_id = message.get('id') if message.get('id') is not None else ''
|
|
|
- logger.info(f"[HTTP] request: ip={client_ip}, method={method}, id={request_id}")
|
|
|
|
|
- response = rpc_handler.handle_json_rpc(dict(self.headers.items()), message, client_ip)
|
|
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ 'MCP public request',
|
|
|
|
|
+ extra={
|
|
|
|
|
+ 'request_id': trace_request_id,
|
|
|
|
|
+ 'jsonrpc_id': request_id,
|
|
|
|
|
+ 'protocol_method': method,
|
|
|
|
|
+ 'client_identity': client_ip,
|
|
|
|
|
+ 'client_request_id_hash': client_request_id_hash,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ response = rpc_handler.handle_json_rpc(
|
|
|
|
|
+ request_headers,
|
|
|
|
|
+ message,
|
|
|
|
|
+ client_ip,
|
|
|
|
|
+ trace_request_id=trace_request_id,
|
|
|
|
|
+ )
|
|
|
self._write_json(response)
|
|
self._write_json(response)
|
|
|
|
|
|
|
|
def _write_json(self, payload):
|
|
def _write_json(self, payload):
|