Skip to content

Commit d6d5499

Browse files
refactor(MCP): Replace MCPRouter with FastMCP Proxy (#8877)
Co-authored-by: openhands <[email protected]>
1 parent 0221f21 commit d6d5499

File tree

10 files changed

+289
-262
lines changed

10 files changed

+289
-262
lines changed

.github/dependabot.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ updates:
1616
mcp-packages:
1717
patterns:
1818
- "mcp"
19-
- "mcpm"
2019
security-all:
2120
applies-to: "security-updates"
2221
patterns:

openhands/runtime/action_execution_server.py

Lines changed: 50 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import asyncio
1010
import base64
1111
import json
12-
import logging
1312
import mimetypes
1413
import os
1514
import shutil
@@ -26,8 +25,6 @@
2625
from fastapi.exceptions import RequestValidationError
2726
from fastapi.responses import FileResponse, JSONResponse
2827
from fastapi.security import APIKeyHeader
29-
from mcpm import MCPRouter, RouterConfig
30-
from mcpm.router.router import logger as mcp_router_logger
3128
from openhands_aci.editor.editor import OHEditor
3229
from openhands_aci.editor.exceptions import ToolError
3330
from openhands_aci.editor.results import ToolResult
@@ -37,6 +34,7 @@
3734
from starlette.exceptions import HTTPException as StarletteHTTPException
3835
from uvicorn import run
3936

37+
from openhands.core.config.mcp_config import MCPStdioServerConfig
4038
from openhands.core.exceptions import BrowserUnavailableException
4139
from openhands.core.logger import openhands_logger as logger
4240
from openhands.events.action import (
@@ -63,20 +61,18 @@
6361
from openhands.runtime.browser import browse
6462
from openhands.runtime.browser.browser_env import BrowserEnv
6563
from openhands.runtime.file_viewer_server import start_file_viewer_server
64+
65+
# Import our custom MCP Proxy Manager
66+
from openhands.runtime.mcp.proxy import MCPProxyManager
6667
from openhands.runtime.plugins import ALL_PLUGINS, JupyterPlugin, Plugin, VSCodePlugin
6768
from openhands.runtime.utils import find_available_tcp_port
6869
from openhands.runtime.utils.bash import BashSession
6970
from openhands.runtime.utils.files import insert_lines, read_lines
70-
from openhands.runtime.utils.log_capture import capture_logs
7171
from openhands.runtime.utils.memory_monitor import MemoryMonitor
7272
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
7373
from openhands.runtime.utils.system_stats import get_system_stats
7474
from openhands.utils.async_utils import call_sync_from_async, wait_all
7575

76-
# Set MCP router logger to the same level as the main logger
77-
mcp_router_logger.setLevel(logger.getEffectiveLevel())
78-
79-
8076
if sys.platform == 'win32':
8177
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
8278

@@ -471,7 +467,7 @@ async def read(self, action: FileReadAction) -> Observation:
471467
filepath = self._resolve_path(action.path, working_dir)
472468
try:
473469
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
474-
with open(filepath, 'rb') as file: # noqa: ASYNC101
470+
with open(filepath, 'rb') as file:
475471
image_data = file.read()
476472
encoded_image = base64.b64encode(image_data).decode('utf-8')
477473
mime_type, _ = mimetypes.guess_type(filepath)
@@ -481,13 +477,13 @@ async def read(self, action: FileReadAction) -> Observation:
481477

482478
return FileReadObservation(path=filepath, content=encoded_image)
483479
elif filepath.lower().endswith('.pdf'):
484-
with open(filepath, 'rb') as file: # noqa: ASYNC101
480+
with open(filepath, 'rb') as file:
485481
pdf_data = file.read()
486482
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
487483
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
488484
return FileReadObservation(path=filepath, content=encoded_pdf)
489485
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
490-
with open(filepath, 'rb') as file: # noqa: ASYNC101
486+
with open(filepath, 'rb') as file:
491487
video_data = file.read()
492488
encoded_video = base64.b64encode(video_data).decode('utf-8')
493489
mime_type, _ = mimetypes.guess_type(filepath)
@@ -497,7 +493,7 @@ async def read(self, action: FileReadAction) -> Observation:
497493

498494
return FileReadObservation(path=filepath, content=encoded_video)
499495

500-
with open(filepath, 'r', encoding='utf-8') as file: # noqa: ASYNC101
496+
with open(filepath, 'r', encoding='utf-8') as file:
501497
lines = read_lines(file.readlines(), action.start, action.end)
502498
except FileNotFoundError:
503499
return ErrorObservation(
@@ -530,7 +526,7 @@ async def write(self, action: FileWriteAction) -> Observation:
530526

531527
mode = 'w' if not file_exists else 'r+'
532528
try:
533-
with open(filepath, mode, encoding='utf-8') as file: # noqa: ASYNC101
529+
with open(filepath, mode, encoding='utf-8') as file:
534530
if mode != 'w':
535531
all_lines = file.readlines()
536532
new_file = insert_lines(insert, all_lines, action.start, action.end)
@@ -654,14 +650,11 @@ def close(self):
654650
plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
655651

656652
client: ActionExecutor | None = None
657-
mcp_router: MCPRouter | None = None
658-
MCP_ROUTER_PROFILE_PATH = os.path.join(
659-
os.path.dirname(__file__), 'mcp', 'config.json'
660-
)
653+
mcp_proxy_manager: MCPProxyManager | None = None
661654

662655
@asynccontextmanager
663656
async def lifespan(app: FastAPI):
664-
global client, mcp_router
657+
global client, mcp_proxy_manager
665658
logger.info('Initializing ActionExecutor...')
666659
client = ActionExecutor(
667660
plugins_to_load,
@@ -676,63 +669,36 @@ async def lifespan(app: FastAPI):
676669
# Check if we're on Windows
677670
is_windows = sys.platform == 'win32'
678671

679-
# Initialize and mount MCP Router (skip on Windows)
672+
# Initialize and mount MCP Proxy Manager (skip on Windows)
680673
if is_windows:
681-
logger.info('Skipping MCP Router initialization on Windows')
682-
mcp_router = None
674+
logger.info('Skipping MCP Proxy initialization on Windows')
675+
mcp_proxy_manager = None
683676
else:
684-
logger.info('Initializing MCP Router...')
685-
mcp_router = MCPRouter(
686-
profile_path=MCP_ROUTER_PROFILE_PATH,
687-
router_config=RouterConfig(
688-
api_key=SESSION_API_KEY,
689-
auth_enabled=bool(SESSION_API_KEY),
690-
),
677+
logger.info('Initializing MCP Proxy Manager...')
678+
# Create a MCP Proxy Manager
679+
mcp_proxy_manager = MCPProxyManager(
680+
auth_enabled=bool(SESSION_API_KEY),
681+
api_key=SESSION_API_KEY,
682+
logger_level=logger.getEffectiveLevel(),
691683
)
684+
mcp_proxy_manager.initialize()
685+
# Mount the proxy to the app
692686
allowed_origins = ['*']
693-
sse_app = await mcp_router.get_sse_server_app(
694-
allow_origins=allowed_origins, include_lifespan=False
695-
)
696-
697-
# Only mount SSE app if MCP Router is initialized (not on Windows)
698-
if mcp_router is not None:
699-
# Check for route conflicts before mounting
700-
main_app_routes = {route.path for route in app.routes}
701-
sse_app_routes = {route.path for route in sse_app.routes}
702-
conflicting_routes = main_app_routes.intersection(sse_app_routes)
703-
704-
if conflicting_routes:
705-
logger.error(f'Route conflicts detected: {conflicting_routes}')
706-
raise RuntimeError(
707-
f'Cannot mount SSE app - conflicting routes found: {conflicting_routes}'
708-
)
709-
710-
app.mount('/', sse_app)
711-
logger.info(
712-
f'Mounted MCP Router SSE app at root path with allowed origins: {allowed_origins}'
713-
)
714-
715-
# Additional debug logging
716-
if logger.isEnabledFor(logging.DEBUG):
717-
logger.debug('Main app routes:')
718-
for route in main_app_routes:
719-
logger.debug(f' {route}')
720-
logger.debug('MCP SSE server app routes:')
721-
for route in sse_app_routes:
722-
logger.debug(f' {route}')
687+
try:
688+
await mcp_proxy_manager.mount_to_app(app, allowed_origins)
689+
except Exception as e:
690+
logger.error(f'Error mounting MCP Proxy: {e}', exc_info=True)
691+
raise RuntimeError(f'Cannot mount MCP Proxy: {e}')
723692

724693
yield
725694

726695
# Clean up & release the resources
727-
logger.info('Shutting down MCP Router...')
728-
if mcp_router:
729-
try:
730-
await mcp_router.shutdown()
731-
logger.info('MCP Router shutdown successfully.')
732-
except Exception as e:
733-
logger.error(f'Error shutting down MCP Router: {e}', exc_info=True)
696+
logger.info('Shutting down MCP Proxy Manager...')
697+
if mcp_proxy_manager:
698+
del mcp_proxy_manager
699+
mcp_proxy_manager = None
734700
else:
735-
logger.info('MCP Router instance not found for shutdown.')
701+
logger.info('MCP Proxy Manager instance not found for shutdown.')
736702

737703
logger.info('Closing ActionExecutor...')
738704
if client:
@@ -824,6 +790,9 @@ async def update_mcp_server(request: Request):
824790
# Check if we're on Windows
825791
is_windows = sys.platform == 'win32'
826792

793+
# Access the global mcp_proxy_manager variable
794+
global mcp_proxy_manager
795+
827796
if is_windows:
828797
# On Windows, just return a success response without doing anything
829798
logger.info(
@@ -838,49 +807,28 @@ async def update_mcp_server(request: Request):
838807
)
839808

840809
# Non-Windows implementation
841-
assert mcp_router is not None
842-
assert os.path.exists(MCP_ROUTER_PROFILE_PATH)
843-
844-
# Use synchronous file operations outside of async function
845-
def read_profile():
846-
with open(MCP_ROUTER_PROFILE_PATH, 'r') as f:
847-
return json.load(f)
848-
849-
current_profile = read_profile()
850-
assert 'default' in current_profile
851-
assert isinstance(current_profile['default'], list)
810+
if mcp_proxy_manager is None:
811+
raise HTTPException(
812+
status_code=500, detail='MCP Proxy Manager is not initialized'
813+
)
852814

853815
# Get the request body
854816
mcp_tools_to_sync = await request.json()
855817
if not isinstance(mcp_tools_to_sync, list):
856818
raise HTTPException(
857819
status_code=400, detail='Request must be a list of MCP tools to sync'
858820
)
859-
860821
logger.info(
861-
f'Updating MCP server to: {json.dumps(mcp_tools_to_sync, indent=2)}.\nPrevious profile: {json.dumps(current_profile, indent=2)}'
822+
f'Updating MCP server with tools: {json.dumps(mcp_tools_to_sync, indent=2)}'
862823
)
863-
current_profile['default'] = mcp_tools_to_sync
864-
865-
# Use synchronous file operations outside of async function
866-
def write_profile(profile):
867-
with open(MCP_ROUTER_PROFILE_PATH, 'w') as f:
868-
json.dump(profile, f)
869-
870-
write_profile(current_profile)
871-
872-
# Manually reload the profile and update the servers
873-
mcp_router.profile_manager.reload()
874-
servers_wait_for_update = mcp_router.get_unique_servers()
875-
async with capture_logs('mcpm.router.router') as log_capture:
876-
await mcp_router.update_servers(servers_wait_for_update)
877-
router_error_log = log_capture.getvalue()
878-
879-
logger.info(
880-
f'MCP router updated successfully with unique servers: {servers_wait_for_update}'
881-
)
882-
if router_error_log:
883-
logger.warning(f'Some MCP servers failed to be added: {router_error_log}')
824+
mcp_tools_to_sync = [MCPStdioServerConfig(**tool) for tool in mcp_tools_to_sync]
825+
try:
826+
await mcp_proxy_manager.update_and_remount(app, mcp_tools_to_sync, ['*'])
827+
logger.info('MCP Proxy Manager updated and remounted successfully')
828+
router_error_log = ''
829+
except Exception as e:
830+
logger.error(f'Error updating MCP Proxy Manager: {e}', exc_info=True)
831+
router_error_log = str(e)
884832

885833
return JSONResponse(
886834
status_code=200,
@@ -915,7 +863,7 @@ async def upload_file(
915863
)
916864

917865
zip_path = os.path.join(full_dest_path, file.filename)
918-
with open(zip_path, 'wb') as buffer: # noqa: ASYNC101
866+
with open(zip_path, 'wb') as buffer:
919867
shutil.copyfileobj(file.file, buffer)
920868

921869
# Extract the zip file
@@ -928,7 +876,7 @@ async def upload_file(
928876
else:
929877
# For single file uploads
930878
file_path = os.path.join(full_dest_path, file.filename)
931-
with open(file_path, 'wb') as buffer: # noqa: ASYNC101
879+
with open(file_path, 'wb') as buffer:
932880
shutil.copyfileobj(file.file, buffer)
933881
logger.debug(f'Uploaded file {file.filename} to {destination}')
934882

openhands/runtime/impl/action_execution/action_execution_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ def get_mcp_config(
435435
# We should always include the runtime as an MCP server whenever there's > 0 stdio servers
436436
updated_mcp_config.sse_servers.append(
437437
MCPSSEServerConfig(
438-
url=self.action_execution_server_url.rstrip('/') + '/sse',
438+
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
439439
api_key=self.session_api_key,
440440
)
441441
)

openhands/runtime/mcp/config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
{
2-
"default": []
2+
"mcpServers": {
3+
"default": {}
4+
},
5+
"tools": []
36
}

openhands/runtime/mcp/proxy/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# MCP Proxy Manager
2+
3+
This module provides a manager class for handling FastMCP proxy instances in OpenHands, including initialization, configuration, and mounting to FastAPI applications.
4+
5+
## Overview
6+
7+
The `MCPProxyManager` class encapsulates all the functionality related to creating, configuring, and managing FastMCP proxy instances. It simplifies the process of:
8+
9+
1. Initializing a FastMCP proxy
10+
2. Configuring the proxy with tools
11+
3. Mounting the proxy to a FastAPI application
12+
4. Updating the proxy configuration
13+
5. Shutting down the proxy
14+
15+
## Usage
16+
17+
### Basic Usage
18+
19+
```python
20+
from openhands.runtime.mcp.proxy import MCPProxyManager
21+
from fastapi import FastAPI
22+
23+
# Create a FastAPI app
24+
app = FastAPI()
25+
26+
# Create a proxy manager
27+
proxy_manager = MCPProxyManager(
28+
name="MyProxyServer",
29+
auth_enabled=True,
30+
api_key="my-api-key"
31+
)
32+
33+
# Initialize the proxy
34+
proxy_manager.initialize()
35+
36+
# Mount the proxy to the app
37+
await proxy_manager.mount_to_app(app, allow_origins=["*"])
38+
39+
# Update the tools configuration
40+
tools = [
41+
{
42+
"name": "my_tool",
43+
"description": "My tool description",
44+
"parameters": {...}
45+
}
46+
]
47+
proxy_manager.update_tools(tools)
48+
49+
# Update and remount the proxy
50+
await proxy_manager.update_and_remount(app, tools, allow_origins=["*"])
51+
52+
# Shutdown the proxy
53+
await proxy_manager.shutdown()
54+
```
55+
56+
### In-Memory Configuration
57+
58+
The `MCPProxyManager` maintains the configuration in-memory, eliminating the need for file-based configuration. This makes it easier to update the configuration and reduces the complexity of the code.
59+
60+
## Benefits
61+
62+
1. **Simplified API**: The `MCPProxyManager` provides a simple and intuitive API for managing FastMCP proxies.
63+
2. **In-Memory Configuration**: Configuration is maintained in-memory, eliminating the need for file I/O operations.
64+
3. **Improved Error Handling**: The manager provides better error handling and logging for proxy operations.
65+
4. **Cleaner Code**: By encapsulating proxy-related functionality in a dedicated class, the code is more maintainable and easier to understand.
66+
67+
## Implementation Details
68+
69+
The `MCPProxyManager` uses the `FastMCP.as_proxy()` method to create a proxy server. It manages the lifecycle of the proxy, including initialization, configuration updates, and shutdown.
70+
71+
When updating the tools configuration, the manager creates a new proxy with the updated configuration and remounts it to the FastAPI application, ensuring that the proxy is always up-to-date with the latest configuration.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
MCP Proxy module for OpenHands.
3+
"""
4+
5+
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
6+
7+
__all__ = ['MCPProxyManager']

0 commit comments

Comments
 (0)