diff --git a/backend/app/app_factory.py b/backend/app/app_factory.py index cd96791..9f42093 100644 --- a/backend/app/app_factory.py +++ b/backend/app/app_factory.py @@ -24,7 +24,7 @@ from app.api.endpoints import ( voiceprint, ) from app.core.config import UPLOAD_DIR -from app.core.middleware import TerminalCheckMiddleware +from app.core.middleware import MCPPathNormalizeMiddleware, TerminalCheckMiddleware from app.mcp import create_mcp_http_app, get_mcp_session_manager from app.services.system_config_service import SystemConfigService @@ -38,6 +38,7 @@ def create_app() -> FastAPI: redoc_url=None, ) + app.add_middleware(MCPPathNormalizeMiddleware) app.add_middleware(TerminalCheckMiddleware) app.add_middleware( CORSMiddleware, @@ -50,8 +51,8 @@ def create_app() -> FastAPI: if UPLOAD_DIR.exists(): app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") - mcp_session_manager = get_mcp_session_manager() mcp_asgi_app = create_mcp_http_app() + mcp_session_manager = get_mcp_session_manager() if mcp_asgi_app is not None: app.mount("/mcp", mcp_asgi_app, name="mcp") diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index a987219..b123ac7 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -5,6 +5,26 @@ from app.services.terminal_service import terminal_service from app.services.jwt_service import jwt_service from app.core.response import create_api_response + +class MCPPathNormalizeMiddleware: + """Normalize the public MCP endpoint to /mcp while keeping the mounted app happy.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and scope.get("path") == "/mcp": + normalized_scope = dict(scope) + normalized_scope["path"] = "/mcp/" + + raw_path = scope.get("raw_path") + if raw_path in (None, b"/mcp"): + normalized_scope["raw_path"] = b"/mcp/" + + scope = normalized_scope + + await self.app(scope, receive, send) + class TerminalCheckMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行 diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index b075bf2..c7de8e0 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import datetime import hmac import json +import logging from typing import Any, Dict, Optional from fastapi.responses import JSONResponse @@ -16,7 +17,7 @@ try: except ImportError: # pragma: no cover - runtime dependency FastMCP = None -from app.core.config import APP_CONFIG +from app.core.config import APP_CONFIG, API_CONFIG from app.core.database import get_db_connection from app.mcp.context import ( MCPRequestContext, @@ -26,6 +27,8 @@ from app.mcp.context import ( ) from app.services import meeting_service +logger = logging.getLogger(__name__) + def _build_absolute_url(path: str) -> str: normalized_base = APP_CONFIG["base_url"].rstrip("/") @@ -181,6 +184,9 @@ class MCPHeaderAuthApp: reset_current_mcp_request(token) +_mcp_http_app = None + + if FastMCP is not None: mcp_server = FastMCP( "iMeeting MCP", @@ -188,6 +194,9 @@ if FastMCP is not None: stateless_http=True, streamable_http_path="/", ) + # Keep FastMCP's server settings aligned with the outer Uvicorn process. + mcp_server.settings.host = API_CONFIG["host"] + mcp_server.settings.port = API_CONFIG["port"] @mcp_server.tool() def get_my_meetings() -> Dict[str, Any]: @@ -237,6 +246,7 @@ if FastMCP is not None: } else: # pragma: no cover - graceful fallback without runtime dependency mcp_server = None + logger.warning("FastMCP is unavailable because the 'mcp' package is not installed; /mcp will not be mounted.") def get_mcp_server(): @@ -244,15 +254,21 @@ def get_mcp_server(): def get_mcp_session_manager(): - if mcp_server is None: + if create_mcp_http_app() is None: return None return mcp_server.session_manager def create_mcp_http_app(): + global _mcp_http_app + if mcp_server is None: return None - return MCPHeaderAuthApp(mcp_server.streamable_http_app()) + if _mcp_http_app is None: + # FastMCP initializes its session manager when the Streamable HTTP app + # is created, so cache the app and reuse it across startup/mounting. + _mcp_http_app = MCPHeaderAuthApp(mcp_server.streamable_http_app()) + return _mcp_http_app def get_mcp_asgi_app(): diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 2a1bd61..1d755d6 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -33,6 +33,28 @@ server { proxy_send_timeout 300s; } + # MCP Streamable HTTP 代理 + # 对外以 /mcp 作为标准入口,避免 MCP Client 处理尾斜杠重定向。 + location = /mcp { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + chunked_transfer_encoding on; + } + + location = /mcp/ { + return 308 /mcp; + } + location = /docs { proxy_pass http://backend:8000/docs; proxy_http_version 1.1; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c271183..103b68e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,22 +1,9 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { ConfigProvider, theme, App as AntdApp } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import apiClient from './utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from './config/api'; -import HomePage from './pages/HomePage'; -import Dashboard from './pages/Dashboard'; -import AdminDashboard from './pages/AdminDashboard'; -import MeetingDetails from './pages/MeetingDetails'; -import MeetingPreview from './pages/MeetingPreview'; -import AdminManagement from './pages/AdminManagement'; -import PromptManagementPage from './pages/PromptManagementPage'; -import PromptConfigPage from './pages/PromptConfigPage'; -import KnowledgeBasePage from './pages/KnowledgeBasePage'; -import EditKnowledgeBase from './pages/EditKnowledgeBase'; -import ClientDownloadPage from './pages/ClientDownloadPage'; -import AccountSettings from './pages/AccountSettings'; -import MeetingCenterPage from './pages/MeetingCenterPage'; import MainLayout from './components/MainLayout'; import menuService from './services/menuService'; import { @@ -28,6 +15,33 @@ import configService from './utils/configService'; import './App.css'; import './styles/console-theme.css'; +const HomePage = lazy(() => import('./pages/HomePage')); +const Dashboard = lazy(() => import('./pages/Dashboard')); +const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); +const MeetingDetails = lazy(() => import('./pages/MeetingDetails')); +const MeetingPreview = lazy(() => import('./pages/MeetingPreview')); +const AdminManagement = lazy(() => import('./pages/AdminManagement')); +const PromptManagementPage = lazy(() => import('./pages/PromptManagementPage')); +const PromptConfigPage = lazy(() => import('./pages/PromptConfigPage')); +const KnowledgeBasePage = lazy(() => import('./pages/KnowledgeBasePage')); +const EditKnowledgeBase = lazy(() => import('./pages/EditKnowledgeBase')); +const ClientDownloadPage = lazy(() => import('./pages/ClientDownloadPage')); +const AccountSettings = lazy(() => import('./pages/AccountSettings')); +const MeetingCenterPage = lazy(() => import('./pages/MeetingCenterPage')); + +const RouteLoading = ({ text = '页面加载中...' }) => ( +
{text}
+