nex_docus/backend/app/mcp/server.py

344 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
Backend-integrated MCP Streamable HTTP server.
"""
from __future__ import annotations
from datetime import datetime
import hmac
from pathlib import Path
from typing import Any, Dict, List
import uuid
from fastapi import HTTPException, Response
from sqlalchemy import select
try:
from mcp.server.fastmcp import FastMCP
except ImportError: # pragma: no cover - runtime dependency
FastMCP = None
from app.core.database import AsyncSessionLocal
from app.models.mcp_bot import MCPBot
from app.models.project import Project, ProjectMember
from app.models.user import User
from app.schemas.project import ProjectResponse
from app.services.project_file_service import project_file_service
from app.services.storage import storage_service
from app.services.project_service import (
count_project_documents,
require_project_read_access,
require_project_write_access,
)
from app.core.config import settings
from app.mcp.context import MCPRequestContext, current_mcp_request
mcp = (
FastMCP(
"NexDocs MCP",
host=settings.HOST,
port=settings.PORT,
stateless_http=True,
json_response=True,
streamable_http_path="/",
)
if FastMCP
else None
)
async def _get_current_user(db) -> User:
ctx = current_mcp_request.get()
if ctx is None:
raise RuntimeError("MCP request context is missing.")
result = await db.execute(select(User).where(User.id == ctx.user_id, User.status == 1))
user = result.scalar_one_or_none()
if not user:
raise RuntimeError("Authenticated MCP user does not exist or is disabled.")
return user
async def _get_project_with_write_access(project_id: int, current_user: User, db):
project, _ = await require_project_write_access(db, project_id, current_user)
return project
def _ensure_file_exists(file_path: Path, path: str) -> None:
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"文件不存在: {path}")
if not file_path.is_file():
raise HTTPException(status_code=400, detail=f"目标不是文件: {path}")
def _ensure_file_not_exists(file_path: Path, path: str) -> None:
if file_path.exists():
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
if mcp is not None:
@mcp.tool(name="list_created_projects", description="获取当前用户创建的项目列表。")
async def list_created_projects(keyword: str = "", limit: int = 100) -> List[Dict[str, Any]]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
result = await db.execute(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
projects = result.scalars().all()
items = []
keyword_lower = keyword.strip().lower()
for project in projects:
project_dict = ProjectResponse.from_orm(project).dict()
project_dict["doc_count"] = count_project_documents(project.storage_key)
if keyword_lower:
haystack = f"{project.name} {project.description or ''}".lower()
if keyword_lower not in haystack:
continue
items.append(project_dict)
return items[: max(limit, 0)]
@mcp.tool(name="create_project", description="创建新项目(按项目名称创建,创建者自动成为项目管理员)。")
async def create_project(name: str, description: str = "") -> Dict[str, Any]:
"""按项目名称创建新项目,返回项目 ID 与存储标识。"""
name = (name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="项目名称不能为空")
if len(name) > 100:
raise HTTPException(status_code=400, detail="项目名称不能超过 100 个字符")
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
# 生成 UUID 作为存储键
storage_key = str(uuid.uuid4())
db_project = Project(
name=name,
description=(description or "").strip() or None,
storage_key=storage_key,
owner_id=current_user.id,
is_public=0,
status=1,
)
db.add(db_project)
await db.commit()
await db.refresh(db_project)
# 创建物理文件夹结构,失败则回滚数据库记录
try:
storage_service.create_project_structure(storage_key)
except Exception as exc: # noqa: BLE001
await db.delete(db_project)
await db.commit()
raise HTTPException(status_code=500, detail=f"项目文件夹创建失败: {exc}")
# 项目创建者自动成为管理员成员
db_member = ProjectMember(
project_id=db_project.id,
user_id=current_user.id,
role="admin",
)
db.add(db_member)
await db.commit()
# 记录操作日志MCP 无 HTTP 请求对象,跳过 request 字段)
try:
from app.core.enums import OperationType
from app.services.log_service import log_service
await log_service.log_project_operation(
db=db,
operation_type=OperationType.CREATE_PROJECT,
project_id=db_project.id,
user=current_user,
detail={"project_name": name, "source": "mcp"},
)
except Exception as exc: # noqa: BLE001
pass
return {
"message": "项目创建成功",
"project_id": db_project.id,
"name": db_project.name,
"storage_key": db_project.storage_key,
}
@mcp.tool(name="get_project_tree", description="获取指定项目的目录树。")
async def get_project_tree(project_id: int) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project, user_role = await require_project_read_access(db, project_id, current_user)
project_root = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_root)
return {
"tree": [item.model_dump() for item in tree],
"user_role": user_role,
"project_name": project.name,
"project_description": project.description,
}
@mcp.tool(name="get_file", description="读取指定项目中的文件内容。")
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project, _ = await require_project_read_access(db, project_id, current_user)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
content = await storage_service.read_file(file_path)
return {"path": path, "content": content}
@mcp.tool(name="create_file", description="在指定项目的路径下创建新文件。")
async def create_file(project_id: int, path: str, content: str = "") -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_not_exists(file_path, path)
await project_file_service.operate_file(
db,
project_id,
project,
"create_file",
path,
current_user,
content=content,
source="mcp",
)
return {
"message": "文件创建成功",
"project_id": project_id,
"path": path,
}
@mcp.tool(name="update_file", description="更新指定项目中已有文件的内容。")
async def update_file(project_id: int, path: str, content: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await project_file_service.save_file(
db,
project_id,
project,
path,
content,
current_user,
source="mcp",
)
return {
"message": "文件更新成功",
"project_id": project_id,
"path": path,
}
@mcp.tool(name="delete_file", description="删除指定项目中的文件。")
async def delete_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await project_file_service.operate_file(
db,
project_id,
project,
"delete",
path,
current_user,
source="mcp",
)
return {
"message": "文件删除成功",
"project_id": project_id,
"path": path,
}
def create_mcp_http_app():
"""Return the MCP streamable HTTP ASGI app."""
if mcp is None:
raise RuntimeError("Package 'mcp' is required to run the MCP endpoint.")
return mcp.streamable_http_app()
def get_mcp_session_manager():
"""Return the MCP streamable HTTP session manager."""
if mcp is None:
raise RuntimeError("Package 'mcp' is required to run the MCP endpoint.")
return mcp.session_manager
class MCPHeaderAuthApp:
"""ASGI wrapper that authenticates incoming MCP requests via bot headers."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {
key.decode("latin-1").lower(): value.decode("latin-1")
for key, value in scope.get("headers", [])
}
bot_id = headers.get("x-bot-id", "").strip()
bot_secret = headers.get("x-bot-secret", "").strip()
if not bot_id or not bot_secret:
response = Response(
content='{"error":"Missing X-Bot-Id or X-Bot-Secret"}',
status_code=401,
media_type="application/json",
)
await response(scope, receive, send)
return
async with AsyncSessionLocal() as db:
result = await db.execute(
select(MCPBot, User)
.join(User, User.id == MCPBot.user_id)
.where(MCPBot.bot_id == bot_id, MCPBot.status == 1, User.status == 1)
)
row = result.first()
if not row:
response = Response(
content='{"error":"Invalid MCP bot"}',
status_code=403,
media_type="application/json",
)
await response(scope, receive, send)
return
mcp_bot, user = row
if not hmac.compare_digest(mcp_bot.bot_secret, bot_secret):
response = Response(
content='{"error":"Invalid MCP secret"}',
status_code=403,
media_type="application/json",
)
await response(scope, receive, send)
return
mcp_bot.last_used_at = datetime.utcnow()
await db.commit()
token = current_mcp_request.set(MCPRequestContext(bot_id=bot_id, user_id=user.id))
try:
await self.app(scope, receive, send)
finally:
current_mcp_request.reset(token)