feat: 添加 Docker 部署支持和 MCP 原生集成

- 添加 Dockerfile、docker-compose.yml 和 Jenkins 流水线配置
- 新增 MCP 原生集成模块 (mcp_native.py)
- 移除旧的 mcp_bridge.py,更新依赖和文档
- 添加部署文档 (Docker 和服务器部署指南)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 14:21:43 +08:00
co-authored by Cursor
parent a41f61bcf6
commit f5143aee09
20 changed files with 1722 additions and 403 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = "sqlite:///./ai_test.db"
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./ai_test.db")
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+23 -11
View File
@@ -391,7 +391,10 @@ def _ensure_default_superadmin():
_ensure_sqlite_columns()
_ensure_default_superadmin()
app = FastAPI(title="质量检测平台", version="0.1.0")
from .mcp_native import mcp_lifespan, mount_mcp_streamable_http
app = FastAPI(title="质量检测平台", version="0.1.0", lifespan=mcp_lifespan)
mount_mcp_streamable_http(app)
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend-admin" / "dist"
FRONTEND_ASSETS_DIR = FRONTEND_DIST_DIR / "assets"
FRONTEND_INDEX_FILE = FRONTEND_DIST_DIR / "index.html"
@@ -1725,16 +1728,12 @@ def mcp_tools(request: Request, db: Session = Depends(get_db)):
}
@app.post("/mcp/invoke", response_model=McpInvokeResponse)
async def mcp_invoke(payload: McpInvokeRequest, request: Request, db: Session = Depends(get_db)):
tool = payload.tool
args = payload.arguments or {}
actor = _resolve_mcp_actor(
request,
db,
api_key=payload.api_key,
require_credential=_mcp_tool_requires_credential(tool),
)
async def _dispatch_mcp_tool(
tool: str,
args: dict[str, Any],
db: Session,
actor: User,
) -> McpInvokeResponse:
try:
if tool == "folder_ensure":
target = str(args["target"])
@@ -2063,6 +2062,19 @@ async def mcp_invoke(payload: McpInvokeRequest, request: Request, db: Session =
return McpInvokeResponse(ok=False, tool=tool, error=str(exc))
@app.post("/mcp/invoke", response_model=McpInvokeResponse)
async def mcp_invoke(payload: McpInvokeRequest, request: Request, db: Session = Depends(get_db)):
tool = payload.tool
args = payload.arguments or {}
actor = _resolve_mcp_actor(
request,
db,
api_key=payload.api_key,
require_credential=_mcp_tool_requires_credential(tool),
)
return await _dispatch_mcp_tool(tool, args, db, actor)
@app.post("/mcp/invoke-batch")
async def mcp_invoke_batch(payload: McpInvokeBatchRequest, request: Request, db: Session = Depends(get_db)):
outputs: list[dict[str, Any]] = []
+158
View File
@@ -0,0 +1,158 @@
"""Native MCP Server (Streamable HTTP) mounted on the FastAPI app."""
from __future__ import annotations
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from mcp import types
from mcp.server.lowlevel.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from sqlalchemy.orm import Session
from starlette.requests import Request as StarletteRequest
from .database import SessionLocal
logger = logging.getLogger(__name__)
SERVER_NAME = "quality-inspection-platform"
SERVER_VERSION = "0.2.0"
MCP_INSTRUCTIONS = (
"Quality inspection platform (质量检测平台). Before complex tasks, read project file "
"docs/mcp_tools_for_ai.md (tool picker, auth, recipes). "
"Typical start: catalog_snapshot. "
"Batch runs: workflow_batch_create → workflow_batch_update → workflow_batch_run. "
"Writes and execution require sto- API Key (Authorization: Bearer or X-API-Key)."
)
_mcp_server = Server(SERVER_NAME, version=SERVER_VERSION, instructions=MCP_INSTRUCTIONS)
_session_manager: StreamableHTTPSessionManager | None = None
def _mcp_transport_security() -> TransportSecuritySettings | None:
if os.getenv("QIP_MCP_DISABLE_DNS_PROTECTION", "").strip().lower() in {"1", "true", "yes", "on"}:
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
hosts = [item.strip() for item in os.getenv("QIP_MCP_ALLOWED_HOSTS", "").split(",") if item.strip()]
origins = [item.strip() for item in os.getenv("QIP_MCP_ALLOWED_ORIGINS", "").split(",") if item.strip()]
if not hosts and not origins:
return None
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=hosts,
allowed_origins=origins,
)
def _tool_specs() -> list[dict[str, Any]]:
from .main import MCP_TOOL_SPECS
return MCP_TOOL_SPECS
def _specs_to_mcp_tools() -> list[types.Tool]:
tools: list[types.Tool] = []
for spec in _tool_specs():
schema = spec.get("input_schema") or {"type": "object"}
tools.append(
types.Tool(
name=str(spec.get("name", "")),
description=str(spec.get("description", "")),
inputSchema=schema,
)
)
return tools
def _starlette_request() -> StarletteRequest | None:
ctx = _mcp_server.request_context
request = ctx.request
return request if isinstance(request, StarletteRequest) else None
def _resolve_actor_for_tool(tool: str, request: StarletteRequest, db: Session):
from .main import _mcp_global_require_api_key, _mcp_tool_requires_credential, _resolve_mcp_actor
require = _mcp_global_require_api_key() or _mcp_tool_requires_credential(tool)
return _resolve_mcp_actor(request, db, require_credential=require)
@_mcp_server.list_tools()
async def _handle_list_tools() -> list[types.Tool]:
request = _starlette_request()
if request is None:
return _specs_to_mcp_tools()
db = SessionLocal()
try:
from .main import _mcp_global_require_api_key
if _mcp_global_require_api_key():
_resolve_actor_for_tool("catalog_snapshot", request, db)
return _specs_to_mcp_tools()
finally:
db.close()
@_mcp_server.call_tool()
async def _handle_call_tool(
name: str,
arguments: dict[str, Any] | None,
) -> types.CallToolResult:
request = _starlette_request()
if request is None:
raise ValueError("MCP request context missing HTTP request")
db = SessionLocal()
try:
try:
actor = _resolve_actor_for_tool(name, request, db)
from .main import _dispatch_mcp_tool
result = await _dispatch_mcp_tool(name, arguments or {}, db, actor)
except HTTPException as exc:
payload = {"ok": False, "tool": name, "data": {}, "error": str(exc.detail)}
return types.CallToolResult(
content=[types.TextContent(type="text", text=json.dumps(payload, ensure_ascii=False, indent=2))],
isError=True,
)
text = json.dumps(result.model_dump(), ensure_ascii=False, indent=2)
return types.CallToolResult(
content=[types.TextContent(type="text", text=text)],
isError=not result.ok,
)
finally:
db.close()
def _get_session_manager() -> StreamableHTTPSessionManager:
global _session_manager
if _session_manager is None:
_session_manager = StreamableHTTPSessionManager(
app=_mcp_server,
json_response=False,
stateless=True,
security_settings=_mcp_transport_security(),
)
return _session_manager
@asynccontextmanager
async def mcp_lifespan(_app: FastAPI):
manager = _get_session_manager()
async with manager.run():
yield
def mount_mcp_streamable_http(fastapi_app: FastAPI, *, path: str = "/mcp") -> None:
"""Register Streamable HTTP MCP endpoint on the FastAPI app."""
manager = _get_session_manager()
@fastapi_app.api_route(path, methods=["GET", "POST", "DELETE"], include_in_schema=False)
async def mcp_streamable_http_endpoint(request: Request) -> None:
await manager.handle_request(request.scope, request.receive, request._send) # type: ignore[attr-defined]
logger.info("MCP Streamable HTTP mounted at %s (stateless)", path)
+2 -1
View File
@@ -12,7 +12,8 @@ import paramiko
from ..schemas import SshProfileOut, SshScriptOut
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
SSH_SCRIPTS_DIR = PROJECT_ROOT / "data" / "ssh-scripts"
_DATA_ROOT = Path(os.getenv("QIP_DATA_DIR", str(PROJECT_ROOT / "data")))
SSH_SCRIPTS_DIR = _DATA_ROOT / "ssh-scripts"
def resolve_script_body(script: SshScriptOut) -> str: