587 lines
24 KiB
Python
587 lines
24 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
Maya MCP HTTP Handler
|
|
This module provides HTTP request handling for the Maya MCP server.
|
|
|
|
Version: 1.0.0
|
|
Author: Jeffrey Tsai
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import traceback
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from port_config import SERVER_HOST, SERVER_PORT
|
|
from log_config import get_logger, initialize_logging
|
|
|
|
# Initialize logging
|
|
initialize_logging()
|
|
logger = get_logger("HTTPHandler")
|
|
|
|
# Global variables
|
|
_server_instance = None
|
|
_server_running = False
|
|
_clients = [] # List of (handler, client_id) tuples
|
|
_clients_lock = threading.RLock() # Lock for thread-safe client list operations
|
|
_event_callbacks = {}
|
|
|
|
class MCPHTTPHandler(BaseHTTPRequestHandler):
|
|
"""HTTP request handler for Maya MCP server"""
|
|
|
|
def do_GET(self):
|
|
"""Handle GET requests"""
|
|
try:
|
|
# Check if this is an SSE request
|
|
accept_header = self.headers.get('Accept', '')
|
|
|
|
logger.debug(f"Received GET request for path: {self.path}")
|
|
logger.debug(f"Headers: {self.headers}")
|
|
|
|
# Support both root path and /events path for SSE
|
|
if ('text/event-stream' in accept_header and
|
|
(self.path == '/' or self.path == '/events')):
|
|
logger.info(f"Received SSE request from {self.client_address[0]}:{self.client_address[1]}")
|
|
self._handle_sse_request()
|
|
else:
|
|
logger.info(f"Received regular request for path: {self.path}")
|
|
self._handle_regular_request()
|
|
except Exception as e:
|
|
logger.error(f"Error occurred while handling GET request: {e}")
|
|
logger.error(traceback.format_exc())
|
|
self._send_error(500, str(e))
|
|
|
|
def do_POST(self):
|
|
"""Handle POST requests"""
|
|
try:
|
|
logger.debug(f"Received POST request for path: {self.path}")
|
|
logger.debug(f"Headers: {self.headers}")
|
|
|
|
# Handle POST request
|
|
self._handle_regular_request()
|
|
except Exception as e:
|
|
logger.error(f"Error occurred while handling POST request: {e}")
|
|
logger.error(traceback.format_exc())
|
|
self._send_error(500, str(e))
|
|
|
|
def _handle_sse_request(self):
|
|
"""Handle Server-Sent Events (SSE) request"""
|
|
client_id = f"client-{int(time.time())}"
|
|
try:
|
|
# Add client to list
|
|
with _clients_lock:
|
|
_clients.append((self, client_id))
|
|
logger.info(f"New SSE client connected: {client_id}")
|
|
|
|
# Send SSE headers
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/event-stream')
|
|
self.send_header('Cache-Control', 'no-cache, no-transform')
|
|
self.send_header('Connection', 'keep-alive')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
self.send_header('X-Accel-Buffering', 'no') # Disable Nginx buffering
|
|
self.end_headers()
|
|
|
|
# Force immediate flush of headers
|
|
if hasattr(self.wfile, 'flush'):
|
|
self.wfile.flush()
|
|
|
|
# Send initial comment to keep connection alive
|
|
logger.debug(f"Sending initial ping to client {client_id}")
|
|
try:
|
|
# Send multiple initial comments to ensure connection is established
|
|
for _ in range(3):
|
|
self.wfile.write(": ping\n\n".encode('utf-8'))
|
|
if hasattr(self.wfile, 'flush'):
|
|
self.wfile.flush()
|
|
time.sleep(0.1) # Brief delay to ensure data is sent
|
|
logger.debug(f"Sent initial pings to client {client_id}")
|
|
except Exception as e:
|
|
logger.error(f"Error sending initial ping: {e}")
|
|
raise
|
|
|
|
# Send connection event immediately
|
|
connection_data = {
|
|
"status": "connected",
|
|
"client_id": client_id,
|
|
"server_port": SERVER_PORT,
|
|
"server_type": "maya",
|
|
"version": "1.0.0",
|
|
"timestamp": int(time.time() * 1000),
|
|
"protocol": "SSE"
|
|
}
|
|
|
|
# Send connection event
|
|
self._send_sse_event("connection", connection_data)
|
|
logger.info(f"Sent connection event to client {client_id}")
|
|
|
|
# Send ready event immediately after
|
|
ready_data = {
|
|
"status": "ready",
|
|
"timestamp": int(time.time() * 1000)
|
|
}
|
|
self._send_sse_event("ready", ready_data)
|
|
logger.info(f"Sent ready event to client {client_id}")
|
|
|
|
# Send initial scene info
|
|
try:
|
|
# import server module dynamically
|
|
import importlib
|
|
import server
|
|
importlib.reload(server)
|
|
scene_info = server.get_scene_info()
|
|
self._send_sse_event("scene_info", scene_info)
|
|
logger.info(f"Sent initial scene info to client {client_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not send initial scene info: {e}")
|
|
logger.debug(traceback.format_exc())
|
|
|
|
# Start a thread to keep the connection alive
|
|
thread = threading.Thread(target=self._sse_connection_thread, args=(client_id,))
|
|
thread.daemon = True
|
|
thread.start()
|
|
logger.info(f"SSE connection thread started for client {client_id}")
|
|
|
|
# Keep connection open until client disconnects
|
|
# This will block until client disconnects
|
|
while True:
|
|
time.sleep(1)
|
|
|
|
except (BrokenPipeError, ConnectionResetError) as e:
|
|
# Client disconnected
|
|
logger.info(f"Client {client_id} disconnected: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error handling SSE request: {e}")
|
|
logger.error(traceback.format_exc())
|
|
finally:
|
|
# Remove client from list
|
|
with _clients_lock:
|
|
for i, (client, cid) in enumerate(_clients):
|
|
if cid == client_id:
|
|
_clients.pop(i)
|
|
break
|
|
logger.info(f"Client {client_id} removed from clients list")
|
|
|
|
def _sse_connection_thread(self, client_id):
|
|
"""SSE connection thread"""
|
|
try:
|
|
# Keep connection alive with periodic pings
|
|
last_ping_time = time.time()
|
|
last_heartbeat_time = time.time()
|
|
connection_active = True
|
|
|
|
while connection_active:
|
|
try:
|
|
# get current time
|
|
current_time = time.time()
|
|
|
|
# send ping every 5 seconds
|
|
if current_time - last_ping_time > 5:
|
|
# check if socket is still valid
|
|
if hasattr(self.wfile, '_sock') and self.wfile._sock is not None:
|
|
try:
|
|
# send ping comment
|
|
self.wfile.write(": ping\n\n".encode('utf-8'))
|
|
if hasattr(self.wfile, 'flush'):
|
|
self.wfile.flush()
|
|
last_ping_time = current_time
|
|
logger.debug(f"Sent ping to client {client_id}")
|
|
except (BrokenPipeError, ConnectionResetError) as e:
|
|
logger.info(f"Client {client_id} disconnected during ping: {e}")
|
|
connection_active = False
|
|
break
|
|
except OSError as e:
|
|
if e.errno == 10038: # Socket operation on non-socket
|
|
logger.info(f"Client {client_id} socket closed")
|
|
connection_active = False
|
|
break
|
|
else:
|
|
logger.error(f"OSError in SSE thread: {e}")
|
|
connection_active = False
|
|
break
|
|
|
|
# send heartbeat event every 30 seconds
|
|
if current_time - last_heartbeat_time > 30:
|
|
# send heartbeat event
|
|
heartbeat_data = {
|
|
"status": "alive",
|
|
"timestamp": int(current_time * 1000)
|
|
}
|
|
success = self._send_sse_event("heartbeat", heartbeat_data)
|
|
if success:
|
|
last_heartbeat_time = current_time
|
|
logger.debug(f"Sent heartbeat event to client {client_id}")
|
|
else:
|
|
logger.warning(f"Failed to send heartbeat to client {client_id}")
|
|
connection_active = False
|
|
break
|
|
|
|
# Sleep briefly to avoid high CPU usage
|
|
time.sleep(0.5)
|
|
except (BrokenPipeError, ConnectionResetError) as e:
|
|
logger.info(f"Client {client_id} disconnected: {e}")
|
|
connection_active = False
|
|
break
|
|
except OSError as e:
|
|
if e.errno == 10038: # Socket operation on non-socket
|
|
logger.info(f"Client {client_id} socket closed")
|
|
connection_active = False
|
|
break
|
|
else:
|
|
logger.error(f"OSError in SSE thread: {e}")
|
|
connection_active = False
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in SSE thread: {e}")
|
|
logger.error(traceback.format_exc())
|
|
connection_active = False
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error in SSE thread: {e}")
|
|
logger.error(traceback.format_exc())
|
|
finally:
|
|
# Always remove client from list when connection is closed
|
|
with _clients_lock:
|
|
for i, (client, cid) in enumerate(_clients):
|
|
if cid == client_id:
|
|
_clients.pop(i)
|
|
break
|
|
logger.info(f"SSE client disconnected: {client_id}")
|
|
|
|
def _send_sse_event(self, event_type, data):
|
|
"""Send SSE event"""
|
|
try:
|
|
# Convert data to JSON
|
|
data_json = json.dumps(data)
|
|
|
|
# Create SSE event
|
|
event = f"event: {event_type}\n"
|
|
event += f"data: {data_json}\n\n"
|
|
|
|
# Send event
|
|
self.wfile.write(event.encode('utf-8'))
|
|
if hasattr(self.wfile, 'flush'):
|
|
self.wfile.flush()
|
|
|
|
logger.debug(f"Sent SSE event: {event_type}")
|
|
except ConnectionResetError as e:
|
|
logger.error(f"Error sending SSE event: {e}")
|
|
# Don't raise the exception, just log it
|
|
# This prevents the SSE loop from crashing when a client disconnects
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error sending SSE event: {e}")
|
|
logger.error(traceback.format_exc())
|
|
return False
|
|
|
|
return True
|
|
|
|
def _handle_regular_request(self):
|
|
"""Handle regular HTTP request"""
|
|
try:
|
|
# Default response
|
|
status_code = 200
|
|
content_type = "application/json"
|
|
response_data = {"status": "ok"}
|
|
|
|
# Handle different paths
|
|
if self.path == "/" or self.path == "/info":
|
|
# Get server info
|
|
response_data = {
|
|
"name": "Maya MCP Server",
|
|
"version": "1.0.0",
|
|
"port": SERVER_PORT
|
|
}
|
|
logger.debug(f"Sending server info: {response_data}")
|
|
elif self.path.startswith("/command"):
|
|
# Execute Maya command
|
|
try:
|
|
# Check if this is a POST request
|
|
if self.command == "POST":
|
|
# Get command from request body
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
if content_length > 0:
|
|
body = self.rfile.read(content_length).decode('utf-8')
|
|
try:
|
|
command_data = json.loads(body)
|
|
command = command_data.get('command', '')
|
|
|
|
if command:
|
|
logger.info(f"Executing command: {command}")
|
|
|
|
# Import Maya commands
|
|
import maya.cmds as cmds
|
|
|
|
# Execute command
|
|
try:
|
|
result = eval(f"cmds.{command}")
|
|
response_data = {
|
|
"status": "success",
|
|
"result": result
|
|
}
|
|
logger.info(f"Command executed successfully: {command}")
|
|
except Exception as e:
|
|
logger.error(f"Error executing command: {e}")
|
|
logger.error(traceback.format_exc())
|
|
status_code = 500
|
|
response_data = {
|
|
"status": "error",
|
|
"error": "Command execution failed",
|
|
"details": str(e)
|
|
}
|
|
else:
|
|
status_code = 400
|
|
response_data = {"error": "No command specified"}
|
|
except json.JSONDecodeError:
|
|
status_code = 400
|
|
response_data = {"error": "Invalid JSON in request body"}
|
|
else:
|
|
status_code = 400
|
|
response_data = {"error": "Empty request body"}
|
|
else:
|
|
status_code = 405
|
|
response_data = {"error": "Method not allowed, use POST for commands"}
|
|
except Exception as e:
|
|
logger.error(f"Error handling command request: {e}")
|
|
logger.error(traceback.format_exc())
|
|
status_code = 500
|
|
response_data = {"error": "Internal server error", "details": str(e)}
|
|
elif self.path == "/scene":
|
|
# Get scene info
|
|
try:
|
|
# First try to import the function
|
|
try:
|
|
from server import get_scene_info
|
|
logger.info("Successfully imported get_scene_info function")
|
|
except ImportError as ie:
|
|
logger.error(f"Error importing get_scene_info: {ie}")
|
|
logger.error(traceback.format_exc())
|
|
status_code = 500
|
|
response_data = {"error": "Failed to import scene info function", "details": str(ie)}
|
|
self._send_response(status_code, content_type, response_data)
|
|
return
|
|
|
|
# Then try to call the function with a timeout
|
|
try:
|
|
logger.info("Attempting to get scene info...")
|
|
response_data = get_scene_info()
|
|
logger.info(f"Scene info retrieved successfully: {len(str(response_data))} bytes")
|
|
except Exception as e:
|
|
logger.error(f"Error calling get_scene_info: {e}")
|
|
logger.error(traceback.format_exc())
|
|
status_code = 500
|
|
response_data = {
|
|
"error": "Failed to get scene info",
|
|
"details": str(e),
|
|
"fallback_info": {
|
|
"status": "error",
|
|
"message": "Could not retrieve scene information",
|
|
"timestamp": int(time.time() * 1000)
|
|
}
|
|
}
|
|
except Exception as outer_e:
|
|
logger.error(f"Outer exception in scene info handler: {outer_e}")
|
|
logger.error(traceback.format_exc())
|
|
status_code = 500
|
|
response_data = {"error": "Internal server error", "details": str(outer_e)}
|
|
else:
|
|
# Unknown path
|
|
status_code = 404
|
|
response_data = {"error": f"Path not found: {self.path}"}
|
|
logger.warning(f"Unknown path requested: {self.path}")
|
|
|
|
# Send response
|
|
self._send_response(status_code, content_type, response_data)
|
|
except Exception as e:
|
|
logger.error(f"Error handling regular request: {e}")
|
|
logger.error(traceback.format_exc())
|
|
self._send_error(500, str(e))
|
|
|
|
def _send_response(self, status_code, content_type, data):
|
|
"""Send HTTP response"""
|
|
try:
|
|
# Send response
|
|
self.send_response(status_code)
|
|
self.send_header('Content-Type', content_type)
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.end_headers()
|
|
|
|
# Send data
|
|
response_json = json.dumps(data)
|
|
self.wfile.write(response_json.encode('utf-8'))
|
|
|
|
logger.debug(f"Sent response with status {status_code}, size: {len(response_json)} bytes")
|
|
except Exception as e:
|
|
logger.error(f"Error sending response: {e}")
|
|
logger.error(traceback.format_exc())
|
|
raise
|
|
|
|
def _send_error(self, status_code, error_message):
|
|
"""Send error response"""
|
|
try:
|
|
# Send error response
|
|
self.send_response(status_code)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.end_headers()
|
|
|
|
# Send error data
|
|
error_data = {"error": error_message}
|
|
error_json = json.dumps(error_data)
|
|
self.wfile.write(error_json.encode('utf-8'))
|
|
|
|
logger.debug(f"Sent error response with status {status_code}: {error_message}")
|
|
except Exception as e:
|
|
logger.error(f"Error sending error response: {e}")
|
|
logger.error(traceback.format_exc())
|
|
# Don't raise here to avoid infinite recursion
|
|
|
|
def log_message(self, format, *args):
|
|
"""Override log_message to use our logger"""
|
|
logger.debug(f"{self.address_string()} - {format % args}")
|
|
|
|
# Server class
|
|
class MCPHTTPServer:
|
|
"""HTTP server for Maya MCP"""
|
|
|
|
def __init__(self, port=SERVER_PORT):
|
|
"""Initialize server"""
|
|
self.port = port
|
|
self.server = None
|
|
self.thread = None
|
|
self.running = False
|
|
|
|
def start(self):
|
|
"""Start server"""
|
|
try:
|
|
# Create server - use ThreadingHTTPServer instead of HTTPServer
|
|
host = "127.0.0.1" # 强制使用 127.0.0.1 作为绑定地址
|
|
print(f"Attempting to start HTTP server on {host}:{self.port}")
|
|
self.server = ThreadingHTTPServer((host, self.port), MCPHTTPHandler)
|
|
|
|
# Get actual port used
|
|
_, self.port = self.server.server_address
|
|
|
|
self.running = True
|
|
|
|
# Run server in a separate thread
|
|
self.thread = threading.Thread(target=self._run_server)
|
|
self.thread.daemon = True
|
|
self.thread.start()
|
|
|
|
print(f"HTTP server started successfully on {host}:{self.port}")
|
|
logger.info(f"HTTP server started on {host}:{self.port}")
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error starting HTTP server: {e}")
|
|
logger.error(f"Error starting HTTP server: {e}")
|
|
logger.error(traceback.format_exc())
|
|
self.stop()
|
|
return False
|
|
|
|
def stop(self):
|
|
"""Stop server"""
|
|
if not self.running:
|
|
return False
|
|
|
|
self.running = False
|
|
|
|
# Stop server
|
|
if self.server:
|
|
try:
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
except:
|
|
pass
|
|
self.server = None
|
|
|
|
logger.info("HTTP server stopped")
|
|
return True
|
|
|
|
def is_running(self):
|
|
"""Check if server is running"""
|
|
return self.running
|
|
|
|
def _run_server(self):
|
|
"""Server main loop"""
|
|
logger.info("HTTP server main loop started")
|
|
|
|
try:
|
|
self.server.serve_forever()
|
|
except Exception as e:
|
|
if self.running:
|
|
logger.error(f"Error in HTTP server loop: {e}")
|
|
logger.error(traceback.format_exc())
|
|
|
|
logger.info("HTTP server main loop ended")
|
|
|
|
# Public functions
|
|
def start_http_server(port=SERVER_PORT):
|
|
"""Start HTTP server"""
|
|
global _server_instance, SERVER_PORT, _server_running
|
|
|
|
try:
|
|
# Check if server is already running
|
|
if _server_running and _server_instance:
|
|
logger.info(f"HTTP server already running on port {SERVER_PORT}")
|
|
return SERVER_PORT
|
|
|
|
# Create and start server
|
|
SERVER_PORT = port
|
|
_server_instance = MCPHTTPServer(port)
|
|
|
|
if _server_instance.start():
|
|
_server_running = True
|
|
SERVER_PORT = _server_instance.port
|
|
return SERVER_PORT
|
|
else:
|
|
_server_instance = None
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error starting HTTP server: {e}")
|
|
logger.error(traceback.format_exc())
|
|
return None
|
|
|
|
def stop_http_server():
|
|
"""Stop HTTP server"""
|
|
global _server_instance, _server_running
|
|
|
|
try:
|
|
if _server_instance and _server_running:
|
|
success = _server_instance.stop()
|
|
if success:
|
|
_server_running = False
|
|
_server_instance = None
|
|
return success
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error stopping HTTP server: {e}")
|
|
logger.error(traceback.format_exc())
|
|
return False
|
|
|
|
def is_http_server_running():
|
|
"""Check if HTTP server is running"""
|
|
global _server_instance, _server_running
|
|
|
|
if _server_instance and _server_running:
|
|
return _server_instance.is_running()
|
|
return False
|
|
|
|
def broadcast_event(event_type, data):
|
|
"""Broadcast event to all connected clients"""
|
|
try:
|
|
with _clients_lock:
|
|
for handler, client_id in _clients:
|
|
try:
|
|
handler._send_sse_event(event_type, data)
|
|
except Exception as e:
|
|
logger.error(f"Error broadcasting event to client {client_id}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error broadcasting event: {e}")
|