from __future__ import annotations import re from typing import Any from uuid import uuid4 from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ...models import Integration from ...utils.timezone import now_cn_naive from ..external_query_service.schemas import ExternalEvidenceItem from .catalog import build_template_catalog from .runtime import MicrosoftLearnMCPRuntime from .schemas import McpCommandPreview, McpRuntimeProbe, McpServerConfigPayload, McpTemplateSpec INTEGRATION_TYPE = "mcp_template_center" _VALID_ENV_VAR = re.compile(r"^[A-Z_][A-Z0-9_]*$") _VALID_NAME_CHARS = re.compile(r"[^a-z0-9_-]+") class McpConfigService: def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) self._catalog = {item.id: item for item in build_template_catalog()} async def get_catalog(self) -> dict[str, Any]: items = [item.model_dump(mode="json") for item in self._catalog.values()] recommended = next((item.id for item in self._catalog.values() if item.recommended), "microsoft_learn") return { "recommended_template_id": recommended, "items": items, } async def list_servers(self) -> list[dict[str, Any]]: _, bundle = await self._get_or_init_bundle() items = [ McpServerConfigPayload.model_validate(self._normalize_server_record(item)).model_dump(mode="json") for item in bundle.get("servers", []) if isinstance(item, dict) ] return sorted(items, key=lambda item: str(item.get("updated_at") or ""), reverse=True) async def create_server( self, *, name: str, display_name: str | None, template_id: str, params: dict[str, Any] | None, ) -> dict[str, Any]: row, bundle = await self._get_or_init_bundle() template = self._get_template(template_id) cleaned_name = self._normalize_server_name(name) servers = [item for item in bundle.get("servers", []) if isinstance(item, dict)] if any(str(item.get("name") or "") == cleaned_name for item in servers): raise ValueError(f"MCP 配置名已存在:{cleaned_name}") cleaned_params = self._validate_params(template, params or {}) preview = self._build_preview(template=template, name=cleaned_name, params=cleaned_params) now_text = now_cn_naive().isoformat() item = McpServerConfigPayload( id=f"mcp_{uuid4().hex[:12]}", name=cleaned_name, display_name=str(display_name or template.name).strip() or template.name, template_id=template.id, transport=template.transport, execution_mode=template.execution_mode, params=cleaned_params, preview=preview, client_apply_state=self._default_client_apply_state(template), runtime_probe=self._default_runtime_probe(template), created_at=now_text, updated_at=now_text, confirmed_at=None, ).model_dump(mode="json") servers.append(item) bundle["servers"] = servers row.config = bundle await self.db.flush() return item async def update_server( self, *, server_id: str, name: str | None = None, display_name: str | None = None, template_id: str | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any]: row, bundle = await self._get_or_init_bundle() servers = [self._normalize_server_record(item) for item in bundle.get("servers", []) if isinstance(item, dict)] target = self._find_server(servers, server_id=server_id) if target is None: raise LookupError("MCP 配置不存在") next_template = self._get_template(str(template_id or target.get("template_id") or "")) next_name = self._normalize_server_name(str(name or target.get("name") or "")) if any( str(item.get("id") or "") != str(server_id) and str(item.get("name") or "") == next_name for item in servers ): raise ValueError(f"MCP 配置名已存在:{next_name}") params_input = params if params is not None else dict(target.get("params") or {}) cleaned_params = self._validate_params(next_template, params_input) preview = self._build_preview(template=next_template, name=next_name, params=cleaned_params) name_changed = next_name != str(target.get("name") or "") template_changed = next_template.id != str(target.get("template_id") or "") params_changed = cleaned_params != dict(target.get("params") or {}) target["name"] = next_name target["display_name"] = str(display_name or target.get("display_name") or next_template.name).strip() or next_template.name target["template_id"] = next_template.id target["transport"] = next_template.transport target["execution_mode"] = next_template.execution_mode target["params"] = cleaned_params target["preview"] = preview.model_dump(mode="json") target["updated_at"] = now_cn_naive().isoformat() if name_changed or template_changed or params_changed: target["client_apply_state"] = self._default_client_apply_state(next_template) target["confirmed_at"] = None target["runtime_probe"] = self._default_runtime_probe(next_template).model_dump(mode="json") bundle["servers"] = servers row.config = bundle await self.db.flush() return McpServerConfigPayload.model_validate(target).model_dump(mode="json") async def validate_server(self, *, server_id: str) -> dict[str, Any]: row, bundle = await self._get_or_init_bundle() servers = [self._normalize_server_record(item) for item in bundle.get("servers", []) if isinstance(item, dict)] target = self._find_server(servers, server_id=server_id) if target is None: raise LookupError("MCP 配置不存在") template = self._get_template(str(target.get("template_id") or "")) cleaned_params = self._validate_params(template, dict(target.get("params") or {})) preview = self._build_preview( template=template, name=str(target.get("name") or ""), params=cleaned_params, ) target["params"] = cleaned_params target["preview"] = preview.model_dump(mode="json") if template.execution_mode == "backend_direct": target["runtime_probe"] = (await self._probe_backend_runtime(template=template, params=cleaned_params)) else: target["runtime_probe"] = self._default_runtime_probe(template).model_dump(mode="json") bundle["servers"] = servers row.config = bundle await self.db.flush() return { "server_id": str(server_id), "is_valid": True, "preview": preview.model_dump(mode="json"), "runtime_probe": dict(target.get("runtime_probe") or {}), } async def confirm_client_apply(self, *, server_id: str) -> dict[str, Any]: row, bundle = await self._get_or_init_bundle() servers = [self._normalize_server_record(item) for item in bundle.get("servers", []) if isinstance(item, dict)] target = self._find_server(servers, server_id=server_id) if target is None: raise LookupError("MCP 配置不存在") template = self._get_template(str(target.get("template_id") or "")) if template.execution_mode == "backend_direct": raise ValueError("后端直连模板不需要“已在本机执行”确认") confirmed_at = now_cn_naive().isoformat() target["client_apply_state"] = "user_confirmed" target["confirmed_at"] = confirmed_at target["updated_at"] = confirmed_at bundle["servers"] = servers row.config = bundle await self.db.flush() return McpServerConfigPayload.model_validate(target).model_dump(mode="json") async def get_preferred_backend_direct_server(self, *, template_id: str | None = None) -> dict[str, Any] | None: _, bundle = await self._get_or_init_bundle() servers = [self._normalize_server_record(item) for item in bundle.get("servers", []) if isinstance(item, dict)] ordered = sorted(servers, key=lambda item: str(item.get("updated_at") or ""), reverse=True) for item in ordered: if str(item.get("execution_mode") or "") != "backend_direct": continue if template_id and str(item.get("template_id") or "") != str(template_id): continue return McpServerConfigPayload.model_validate(item).model_dump(mode="json") return None async def search_external(self, *, query: str, result_limit: int = 3) -> list[ExternalEvidenceItem]: query_text = str(query or "").strip() if not query_text: return [] server = await self.get_preferred_backend_direct_server(template_id="microsoft_learn") if not server: return [] template = self._get_template(str(server.get("template_id") or "")) if template.id != "microsoft_learn": return [] runtime = MicrosoftLearnMCPRuntime() return await runtime.search(query=query_text, result_limit=max(1, int(result_limit or 3))) async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]: row = ( await self.db.execute( select(Integration).where( and_( Integration.tenant_id == self.tenant_id, Integration.type == INTEGRATION_TYPE, ) ) ) ).scalar_one_or_none() if row is None: row = Integration( tenant_id=self.tenant_id, type=INTEGRATION_TYPE, is_active=True, config={"version": 1, "servers": []}, ) self.db.add(row) await self.db.flush() bundle = dict(row.config or {}) if isinstance(row.config, dict) else {} if not isinstance(bundle.get("servers"), list): bundle["servers"] = [] if not isinstance(bundle.get("version"), int): bundle["version"] = 1 if bundle != row.config: row.config = bundle await self.db.flush() return row, bundle def _get_template(self, template_id: str) -> McpTemplateSpec: template = self._catalog.get(str(template_id or "")) if template is None: raise ValueError(f"未知 MCP 模板:{template_id}") return template def _normalize_server_record(self, item: dict[str, Any]) -> dict[str, Any]: normalized = dict(item or {}) template = self._get_template(str(normalized.get("template_id") or "")) normalized["transport"] = template.transport normalized["execution_mode"] = template.execution_mode if template.execution_mode == "backend_direct": normalized["client_apply_state"] = "not_required" else: state = str(normalized.get("client_apply_state") or "").strip() normalized["client_apply_state"] = state if state in {"pending_local_apply", "user_confirmed"} else "pending_local_apply" if not isinstance(normalized.get("runtime_probe"), dict): normalized["runtime_probe"] = self._default_runtime_probe(template).model_dump(mode="json") if not isinstance(normalized.get("preview"), dict): normalized["preview"] = self._build_preview( template=template, name=str(normalized.get("name") or ""), params=dict(normalized.get("params") or {}), ).model_dump(mode="json") return normalized @staticmethod def _default_client_apply_state(template: McpTemplateSpec) -> str: return "not_required" if template.execution_mode == "backend_direct" else "pending_local_apply" @staticmethod def _default_runtime_probe(template: McpTemplateSpec) -> McpRuntimeProbe: if template.execution_mode == "backend_direct": return McpRuntimeProbe( status="unknown", checked_at=None, message="保存后由 PMFlow 后端直连;点击“重新校验”可执行 MCP 握手探测。", tool_names=[], ) return McpRuntimeProbe( status="local_bridge_required", checked_at=None, message="该模板依赖 PMFlow Local Bridge 或兼容 MCP host 在客户本机承载。", tool_names=[], ) @staticmethod def _normalize_server_name(value: str) -> str: normalized = _VALID_NAME_CHARS.sub("-", str(value or "").strip().lower()) normalized = re.sub(r"-{2,}", "-", normalized).strip("-_") if not normalized: raise ValueError("MCP 配置名不能为空") return normalized[:80] @staticmethod def _find_server(servers: list[dict[str, Any]], *, server_id: str) -> dict[str, Any] | None: for item in servers: if str(item.get("id") or "") == str(server_id or ""): return item return None def _validate_params(self, template: McpTemplateSpec, params: dict[str, Any]) -> dict[str, Any]: raw = dict(params or {}) if template.id == "microsoft_learn": return {} if template.id == "github": for forbidden_key in ("access_token", "token", "personal_access_token", "pat"): if str(raw.get(forbidden_key) or "").strip(): raise ValueError("GitHub MCP 只接受 Token 环境变量名,不要直接填写明文 token") env_var_name = str(raw.get("token_env_var_name") or "").strip().upper() if not env_var_name: raise ValueError("token_env_var_name 不能为空") if not _VALID_ENV_VAR.match(env_var_name): raise ValueError("token_env_var_name 必须是合法的环境变量名") return {"token_env_var_name": env_var_name} if template.id == "filesystem": value = raw.get("allowed_paths") paths: list[str] = [] if isinstance(value, str): paths = [item.strip() for item in value.splitlines() if item.strip()] elif isinstance(value, list): paths = [str(item).strip() for item in value if str(item).strip()] if not paths: raise ValueError("allowed_paths 至少需要一个目录") return {"allowed_paths": paths} if template.id == "playwright": output_dir = str(raw.get("output_dir") or "~/.pmflow/mcp/playwright-output").strip() return {"output_dir": output_dir} raise ValueError(f"未知模板校验规则:{template.id}") def _build_preview(self, *, template: McpTemplateSpec, name: str, params: dict[str, Any]) -> McpCommandPreview: if template.id == "microsoft_learn": return McpCommandPreview( add_command="无需本机命令。保存后由 PMFlow 后端直接连接 Microsoft Learn MCP。", verify_command="点击“重新校验”后,PMFlow 会从后端执行 MCP initialize 和 tools/list。", expected_output="校验成功后,你会看到后端可用工具,例如 microsoft_docs_search、microsoft_docs_fetch。", ) if template.id == "github": env_name = str(params["token_env_var_name"]) return McpCommandPreview( add_command=( f"需要在客户本机的 PMFlow Local Bridge 或兼容 MCP host 中接入 GitHub MCP," f"并引用环境变量 {env_name}。" ), verify_command="本机 bridge/host 接好后,再回到这里执行“标记为已在本机执行”。", expected_output=f"bridge/host 中应能看到 GitHub MCP 已启用,并通过环境变量 {env_name} 读取 token。", ) if template.id == "filesystem": quoted_paths = " ".join(f'"{item}"' for item in params["allowed_paths"]) return McpCommandPreview( add_command=( "需要在客户本机的 PMFlow Local Bridge 或兼容 MCP host 中启动 Filesystem MCP," f"允许目录:{quoted_paths}" ), verify_command="本机 bridge/host 接好后,再回到这里执行“标记为已在本机执行”。", expected_output=f"bridge/host 中应能看到 Filesystem MCP 已启用,并暴露目录 {quoted_paths}。", ) if template.id == "playwright": output_dir = str(params["output_dir"]) return McpCommandPreview( add_command=( "需要在客户本机的 PMFlow Local Bridge 或兼容 MCP host 中启动 Playwright MCP," f"输出目录:\"{output_dir}\"" ), verify_command="本机 bridge/host 接好后,再回到这里执行“标记为已在本机执行”。", expected_output=f"bridge/host 中应能看到 Playwright MCP 已启用,输出目录为 {output_dir}。", ) raise ValueError(f"未知模板命令生成规则:{template.id}") async def _probe_backend_runtime(self, *, template: McpTemplateSpec, params: dict[str, Any]) -> dict[str, Any]: _ = params if template.id == "microsoft_learn": return await MicrosoftLearnMCPRuntime().probe() return { "status": "error", "checked_at": now_cn_naive().isoformat(), "message": f"模板 {template.id} 暂未实现后端直连 runtime。", "tool_names": [], }