Skip to content

Commit bc2e17f

Browse files
authored
✨ MCP Service Containerization Integration #1368
✨ MCP Service Containerization Integration #1368
2 parents 4125b9d + 24d7f7f commit bc2e17f

File tree

18 files changed

+2248
-536
lines changed

18 files changed

+2248
-536
lines changed

backend/apps/remote_mcp_app.py

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import logging
22
from typing import Optional
33

4-
from fastapi import APIRouter, Header, HTTPException
4+
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
55
from fastapi.responses import JSONResponse
66
from http import HTTPStatus
77

8-
from consts.const import NEXENT_MCP_DOCKER_IMAGE
8+
from consts.const import NEXENT_MCP_DOCKER_IMAGE, ENABLE_UPLOAD_IMAGE
99
from consts.exceptions import MCPConnectionError, MCPNameIllegal, MCPContainerError
1010
from consts.model import MCPConfigRequest
1111
from services.remote_mcp_service import (
@@ -14,7 +14,9 @@
1414
get_remote_mcp_server_list,
1515
check_mcp_health_and_update_db,
1616
delete_mcp_by_container_id,
17+
upload_and_start_mcp_image,
1718
)
19+
from database.remote_mcp_db import check_mcp_name_exists
1820
from services.tool_configuration_service import get_tool_from_remote_mcp_server
1921
from services.mcp_container_service import MCPContainerManager
2022
from utils.auth_utils import get_current_user_id
@@ -116,6 +118,7 @@ async def get_remote_proxies(
116118
return JSONResponse(
117119
status_code=HTTPStatus.OK,
118120
content={"remote_mcp_server_list": remote_mcp_server_list,
121+
"enable_upload_image": ENABLE_UPLOAD_IMAGE,
119122
"status": "success"}
120123
)
121124
except Exception as e:
@@ -196,6 +199,11 @@ async def add_mcp_from_config(
196199
errors.append(f"{service_name}: port is required")
197200
continue
198201

202+
# Check if MCP service name already exists before starting container
203+
if check_mcp_name_exists(mcp_name=service_name, tenant_id=tenant_id):
204+
errors.append(f"{service_name}: MCP name already exists")
205+
continue
206+
199207
# Build full command to run inside nexent/nexent-mcp image
200208
full_command = [
201209
"python",
@@ -224,22 +232,13 @@ async def add_mcp_from_config(
224232
)
225233

226234
# Register to remote MCP server list
227-
try:
228-
await add_remote_mcp_server_list(
229-
tenant_id=tenant_id,
230-
user_id=user_id,
231-
remote_mcp_server=container_info["mcp_url"],
232-
remote_mcp_server_name=service_name,
233-
container_id=container_info["container_id"],
234-
)
235-
except MCPNameIllegal:
236-
# If name already exists, try to stop the container we just created
237-
try:
238-
await container_manager.stop_mcp_container(container_info["container_id"])
239-
except Exception:
240-
pass
241-
errors.append(f"{service_name}: MCP name already exists")
242-
continue
235+
await add_remote_mcp_server_list(
236+
tenant_id=tenant_id,
237+
user_id=user_id,
238+
remote_mcp_server=container_info["mcp_url"],
239+
remote_mcp_server_name=service_name,
240+
container_id=container_info["container_id"],
241+
)
243242

244243
results.append({
245244
"service_name": service_name,
@@ -251,10 +250,12 @@ async def add_mcp_from_config(
251250
})
252251

253252
except MCPContainerError as e:
254-
logger.error(f"Failed to start MCP container {service_name}: {e}")
253+
logger.error(
254+
f"Failed to start MCP container {service_name}: {e}")
255255
errors.append(f"{service_name}: {str(e)}")
256256
except Exception as e:
257-
logger.error(f"Unexpected error adding MCP {service_name}: {e}")
257+
logger.error(
258+
f"Unexpected error adding MCP {service_name}: {e}")
258259
errors.append(f"{service_name}: {str(e)}")
259260

260261
if errors and not results:
@@ -404,3 +405,62 @@ async def get_container_logs(
404405
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
405406
detail=f"Failed to get container logs: {str(e)}"
406407
)
408+
409+
410+
# Conditionally add upload-image route based on ENABLE_UPLOAD_IMAGE setting
411+
if ENABLE_UPLOAD_IMAGE:
412+
@router.post("/upload-image")
413+
async def upload_mcp_image(
414+
file: UploadFile = File(..., description="Docker image tar file"),
415+
port: int = Form(..., ge=1, le=65535,
416+
description="Host port to expose the MCP server on (1-65535)"),
417+
service_name: Optional[str] = Form(
418+
None, description="Name for the MCP service (auto-generated if not provided)"),
419+
env_vars: Optional[str] = Form(
420+
None, description="Environment variables as JSON string"),
421+
authorization: Optional[str] = Header(None)
422+
):
423+
"""
424+
Upload Docker image tar file and start MCP container.
425+
426+
Container naming: {filename-without-extension}-{tenant-id[:8]}-{user-id[:8]}
427+
"""
428+
try:
429+
user_id, tenant_id = get_current_user_id(authorization)
430+
431+
# Read file content
432+
content = await file.read()
433+
434+
# Call service layer to handle the business logic
435+
result = await upload_and_start_mcp_image(
436+
tenant_id=tenant_id,
437+
user_id=user_id,
438+
file_content=content,
439+
filename=file.filename,
440+
port=port,
441+
service_name=service_name,
442+
env_vars=env_vars,
443+
)
444+
445+
return JSONResponse(status_code=HTTPStatus.OK, content=result)
446+
447+
except ValueError as e:
448+
logger.error(f"Validation error: {e}")
449+
raise HTTPException(
450+
status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
451+
except MCPNameIllegal as e:
452+
logger.error(f"MCP name conflict: {e}")
453+
raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e))
454+
except MCPContainerError as e:
455+
logger.error(f"Container error: {e}")
456+
raise HTTPException(
457+
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e))
458+
except Exception as e:
459+
logger.error(f"Failed to upload and start MCP container: {e}")
460+
raise HTTPException(
461+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
462+
detail=f"Failed to upload and start MCP container: {str(e)}"
463+
)
464+
else:
465+
logger.info(
466+
"MCP image upload feature is disabled (ENABLE_UPLOAD_IMAGE=false)")

backend/consts/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ class VectorDatabaseType(str, Enum):
130130
"DISABLE_CELERY_FLOWER", "false").lower() == "true"
131131
DOCKER_ENVIRONMENT = os.getenv("DOCKER_ENVIRONMENT", "false").lower() == "true"
132132
NEXENT_MCP_DOCKER_IMAGE = os.getenv("NEXENT_MCP_DOCKER_IMAGE", "nexent/nexent-mcp:latest")
133+
ENABLE_UPLOAD_IMAGE = os.getenv("ENABLE_UPLOAD_IMAGE", "false").lower() == "true"
133134

134135

135136
# Celery Configuration

backend/consts/model.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,10 @@ class GeneratePromptRequest(BaseModel):
232232
task_description: str
233233
agent_id: int
234234
model_id: int
235-
tool_ids: Optional[List[int]] = None # Optional: tool IDs from frontend (takes precedence over database query)
236-
sub_agent_ids: Optional[List[int]] = None # Optional: sub-agent IDs from frontend (takes precedence over database query)
235+
tool_ids: Optional[List[int]] = Field(
236+
None, description="Optional: tool IDs from frontend (takes precedence over database query)")
237+
sub_agent_ids: Optional[List[int]] = Field(
238+
None, description="Optional: sub-agent IDs from frontend (takes precedence over database query)")
237239

238240

239241
class GenerateTitleRequest(BaseModel):
@@ -399,19 +401,22 @@ def default(cls) -> "MemoryAgentShareMode":
399401
# ---------------------------------------------------------------------------
400402
class VoiceConnectivityRequest(BaseModel):
401403
"""Request model for voice service connectivity check"""
402-
model_type: str = Field(..., description="Type of model to check ('stt' or 'tts')")
404+
model_type: str = Field(...,
405+
description="Type of model to check ('stt' or 'tts')")
403406

404407

405408
class VoiceConnectivityResponse(BaseModel):
406409
"""Response model for voice service connectivity check"""
407-
connected: bool = Field(..., description="Whether the service is connected")
410+
connected: bool = Field(...,
411+
description="Whether the service is connected")
408412
model_type: str = Field(..., description="Type of model checked")
409413
message: str = Field(..., description="Status message")
410414

411415

412416
class TTSRequest(BaseModel):
413417
"""Request model for TTS text-to-speech conversion"""
414-
text: str = Field(..., min_length=1, description="Text to convert to speech")
418+
text: str = Field(..., min_length=1,
419+
description="Text to convert to speech")
415420
stream: bool = Field(True, description="Whether to stream the audio")
416421

417422

@@ -435,7 +440,8 @@ class ToolValidateRequest(BaseModel):
435440
class MCPServerConfig(BaseModel):
436441
"""Configuration for a single MCP server"""
437442
command: str = Field(..., description="Command to run (e.g., 'npx')")
438-
args: List[str] = Field(default_factory=list, description="Command arguments")
443+
args: List[str] = Field(default_factory=list,
444+
description="Command arguments")
439445
env: Optional[Dict[str, str]] = Field(
440446
None, description="Environment variables for the MCP server")
441447
port: Optional[int] = Field(
@@ -449,4 +455,4 @@ class MCPServerConfig(BaseModel):
449455
class MCPConfigRequest(BaseModel):
450456
"""Request model for adding MCP servers from configuration"""
451457
mcpServers: Dict[str, MCPServerConfig] = Field(
452-
..., description="Dictionary of MCP server configurations")
458+
..., description="Dictionary of MCP server configurations")

backend/services/mcp_container_service.py

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,44 @@ def __init__(self, docker_socket_path: Optional[str] = None):
4242
)
4343
# Create container client from config
4444
self.client = create_container_client_from_config(config)
45-
logger.info("MCPContainerManager initialized using SDK container module")
45+
logger.info(
46+
"MCPContainerManager initialized using SDK container module")
4647
except ContainerError as e:
4748
logger.error(f"Failed to initialize container manager: {e}")
4849
raise MCPContainerError(f"Cannot connect to Docker: {e}")
4950

51+
async def load_image_from_tar_file(self, tar_file_path: str) -> str:
52+
"""
53+
Load Docker image from tar file
54+
55+
Args:
56+
tar_file_path: Path to the tar file containing the Docker image
57+
58+
Returns:
59+
Image name/tag that was loaded
60+
61+
Raises:
62+
MCPContainerError: If image loading fails
63+
"""
64+
try:
65+
# Load image from tar file
66+
with open(tar_file_path, 'rb') as tar_file:
67+
images = self.client.client.images.load(tar_file.read())
68+
69+
if not images:
70+
raise MCPContainerError("No images found in tar file")
71+
72+
# Get the first loaded image
73+
loaded_image = images[0]
74+
image_name = loaded_image.tags[0] if loaded_image.tags else str(
75+
loaded_image.id)
76+
77+
except Exception as e:
78+
logger.error(f"Failed to load image from tar file: {e}")
79+
raise MCPContainerError(f"Failed to load image from tar file: {e}")
80+
logger.info(f"Successfully loaded image: {image_name}")
81+
return image_name
82+
5083
async def start_mcp_container(
5184
self,
5285
service_name: str,
@@ -73,8 +106,6 @@ async def start_mcp_container(
73106
MCPContainerError: If container startup fails
74107
"""
75108
try:
76-
if not full_command:
77-
raise MCPContainerError("full_command is required to start MCP container")
78109
result = await self.client.start_container(
79110
service_name=service_name,
80111
tenant_id=tenant_id,
@@ -87,7 +118,8 @@ async def start_mcp_container(
87118
# Map SDK response to existing interface (mcp_url instead of service_url)
88119
return {
89120
"container_id": result["container_id"],
90-
"mcp_url": result["service_url"], # Map service_url to mcp_url for compatibility
121+
# Map service_url to mcp_url for compatibility
122+
"mcp_url": result["service_url"],
91123
"host_port": result["host_port"],
92124
"status": result["status"],
93125
"container_name": result.get("container_name"),
@@ -99,6 +131,54 @@ async def start_mcp_container(
99131
logger.error(f"MCP connection error: {e}")
100132
raise MCPConnectionError(f"MCP connection failed: {e}")
101133

134+
async def start_mcp_container_from_tar(
135+
self,
136+
tar_file_path: str,
137+
service_name: str,
138+
tenant_id: str,
139+
user_id: str,
140+
env_vars: Optional[Dict[str, str]] = None,
141+
host_port: Optional[int] = None,
142+
full_command: Optional[List[str]] = None,
143+
) -> Dict[str, str]:
144+
"""
145+
Load image from tar file and start MCP container
146+
147+
Args:
148+
tar_file_path: Path to the tar file containing the Docker image
149+
service_name: Name of the MCP service
150+
tenant_id: Tenant ID for isolation
151+
user_id: User ID for isolation
152+
env_vars: Optional environment variables
153+
host_port: Optional host port to bind
154+
full_command: Optional command to run in container
155+
156+
Returns:
157+
Dictionary with container_id, mcp_url, host_port, and status
158+
159+
Raises:
160+
MCPContainerError: If container startup fails
161+
"""
162+
try:
163+
# Load image from tar file
164+
image_name = await self.load_image_from_tar_file(tar_file_path)
165+
166+
# Start container with the loaded image
167+
return await self.start_mcp_container(
168+
service_name=service_name,
169+
tenant_id=tenant_id,
170+
user_id=user_id,
171+
env_vars=env_vars,
172+
host_port=host_port,
173+
image=image_name,
174+
full_command=full_command,
175+
)
176+
177+
except Exception as e:
178+
logger.error(f"Failed to start MCP container from tar file: {e}")
179+
raise MCPContainerError(
180+
f"Failed to start container from tar file: {e}")
181+
102182
async def stop_mcp_container(self, container_id: str) -> bool:
103183
"""
104184
Stop and remove MCP container
@@ -117,7 +197,7 @@ async def stop_mcp_container(self, container_id: str) -> bool:
117197
stop_result = await self.client.stop_container(container_id)
118198
if not stop_result:
119199
return False
120-
200+
121201
# Then remove the container
122202
remove_result = await self.client.remove_container(container_id)
123203
return remove_result

0 commit comments

Comments
 (0)