refactor: replace mcp_resources API with direct /robots/ static file access

- Remove routes/mcp_resources.py (ui:// URI resolver endpoint)
- Frontend now directly accesses /robots/{bot_id}/skills/{server}/apps/{resource}.html
- Add Daytona fallback middleware to fetch files from sandbox on 404
- Add utils/daytona_file_fetcher.py for on-demand single file download

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
朱潮 2026-05-20 18:58:58 +08:00
parent 209a8c344d
commit b4f8bb2935
3 changed files with 81 additions and 80 deletions

View File

@ -81,7 +81,6 @@ from utils.log_util.logger import init_with_fastapi
logger = logging.getLogger('app')
from routes import chat, files, projects, system, skill_manager, database, memory
from routes.mcp_resources import router as mcp_resources_router
from routes.webdav import wsgidav_app
@ -189,6 +188,36 @@ app.mount("/public", StaticFiles(directory="public"), name="static")
# Mount robot projects directory as static files (supports HTML/CSS/JS/images)
app.mount("/robots", StaticFiles(directory="projects/robot", html=True), name="robots")
# Daytona fallback middleware: fetch file from sandbox when local file is missing
from utils.settings import DAYTONA_ENABLED
if DAYTONA_ENABLED:
import asyncio
from pathlib import Path
from starlette.responses import FileResponse
PROJECT_ROOT = Path(__file__).parent
@app.middleware("http")
async def daytona_robots_fallback(request, call_next):
response = await call_next(request)
if response.status_code == 404 and request.url.path.startswith("/robots/"):
path_after = request.url.path.removeprefix("/robots/")
parts = path_after.split("/", 1)
if len(parts) == 2:
bot_id, rel_path = parts
local_path = PROJECT_ROOT / "projects" / "robot" / bot_id / rel_path
if not local_path.is_file():
from utils.daytona_file_fetcher import fetch_file_from_daytona
fetched = await asyncio.to_thread(
fetch_file_from_daytona, bot_id, rel_path, local_path
)
if fetched and local_path.is_file():
return FileResponse(str(local_path))
return response
# Add CORS middleware for frontend pages
app.add_middleware(
CORSMiddleware,
@ -210,8 +239,6 @@ app.include_router(skill_manager.router)
app.include_router(database.router)
app.include_router(memory.router)
# MCP App resources endpoint
app.include_router(mcp_resources_router)
# 挂载 WsgiDAVWSGI 应用通过 WSGIMiddleware 集成到 ASGI

View File

@ -1,77 +0,0 @@
"""
REST endpoint for serving MCP App resources.
Maps ui:// URIs to static HTML App files under the bot's skill directory.
GET /api/v1/mcp/resources?uri=ui://data-dashboard/chart&bot_id=xxx
-> projects/robot/{bot_id}/skills/data-dashboard/apps/chart.html
"""
import logging
from pathlib import Path
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import HTMLResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mcp", tags=["mcp-resources"])
PROJECT_ROOT = Path(__file__).parent.parent
# In-memory cache: (bot_id, uri) -> html string
_resource_cache: dict[tuple[str, str], str] = {}
def _resolve_uri_to_path(uri: str, bot_id: str) -> Path:
"""Resolve a ui:// URI to a local file path.
URI format: ui://{server-name}/{resource-name}
File path: projects/robot/{bot_id}/skills/{server-name}/apps/{resource-name}.html
"""
if not uri.startswith("ui://"):
raise ValueError(f"Invalid URI scheme: {uri}")
rest = uri[5:] # Remove "ui://"
parts = rest.split("/", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValueError(f"Invalid URI format: {uri}")
server_name, resource_name = parts
# Prevent path traversal
if ".." in server_name or "/" in server_name:
raise ValueError(f"Invalid server name: {server_name}")
safe_name = Path(resource_name).name
if safe_name != resource_name:
raise ValueError(f"Invalid resource name: {resource_name}")
return PROJECT_ROOT / "projects" / "robot" / bot_id / "skills" / server_name / "apps" / f"{resource_name}.html"
@router.get("/resources")
async def get_resource(
uri: str = Query(..., description="Resource URI (e.g. ui://data-dashboard/chart)"),
bot_id: str = Query(..., description="Bot ID"),
):
"""Fetch an MCP App HTML resource by URI and bot_id.
Returns the static HTML App that should be loaded into an iframe.
The host sends tool result data to the iframe via postMessage.
"""
cache_key = (bot_id, uri)
if cache_key in _resource_cache:
return HTMLResponse(content=_resource_cache[cache_key], media_type="text/html")
try:
file_path = _resolve_uri_to_path(uri, bot_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Resource not found: {uri} (bot_id={bot_id})")
html = file_path.read_text(encoding="utf-8")
_resource_cache[cache_key] = html
return HTMLResponse(content=html, media_type="text/html")

View File

@ -0,0 +1,51 @@
"""On-demand single file fetcher from Daytona sandbox."""
import logging
from pathlib import Path
from utils.settings import DAYTONA_ENABLED, DAYTONA_API_KEY, DAYTONA_SERVER_URL
logger = logging.getLogger('app')
REMOTE_WORKSPACE_ROOT = "/workspace"
def fetch_file_from_daytona(bot_id: str, relative_path: str, local_path: Path) -> bool:
"""Fetch a single file from the Daytona sandbox and save it locally.
Args:
bot_id: The bot ID (sandbox is named "bot-{bot_id}")
relative_path: Path relative to the bot's workspace root
local_path: Local filesystem path where the file should be saved
Returns:
True if the file was successfully fetched and saved, False otherwise
"""
if not (DAYTONA_ENABLED and DAYTONA_API_KEY and DAYTONA_SERVER_URL):
return False
try:
from daytona import Daytona, DaytonaConfig
config = DaytonaConfig(api_key=DAYTONA_API_KEY, api_url=DAYTONA_SERVER_URL)
client = Daytona(config)
sandbox_name = f"bot-{bot_id}"
sandbox = client.get(sandbox_name)
if sandbox.state not in ("Started", "Creating"):
logger.warning(f"Sandbox {sandbox_name} not running (state={sandbox.state})")
return False
remote_path = f"{REMOTE_WORKSPACE_ROOT}/{relative_path}"
content = sandbox.fs.download_file(remote_path)
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(content)
logger.info(f"Fetched file from Daytona: {remote_path} -> {local_path}")
return True
except Exception as e:
logger.warning(f"Failed to fetch file from Daytona for bot {bot_id}: {e}")
return False