PMFlow/backend/app/services/mcp_config/service.py

429 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 DuckDuckGoSearchRuntime, 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,
query_kind: str = "unknown",
) -> list[ExternalEvidenceItem]:
query_text = str(query or "").strip()
if not query_text:
return []
template_ids = self._preferred_backend_direct_template_ids(query_kind=query_kind)
if not template_ids:
return []
server = await self._get_preferred_backend_direct_server_by_templates(template_ids=template_ids)
if not server:
return []
template = self._get_template(str(server.get("template_id") or ""))
if template.id == "microsoft_learn":
runtime = MicrosoftLearnMCPRuntime()
return await runtime.search(query=query_text, result_limit=max(1, int(result_limit or 3)))
if template.id == "duckduckgo":
runtime = DuckDuckGoSearchRuntime()
return await runtime.search(
query=query_text,
result_limit=max(1, int(result_limit or 3)),
query_kind=query_kind,
)
return []
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
async def _get_preferred_backend_direct_server_by_templates(self, *, template_ids: list[str]) -> dict[str, Any] | None:
preferred = [str(item or "").strip() for item in list(template_ids or []) if str(item or "").strip()]
if not preferred:
return 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 template_id in preferred:
for item in ordered:
if str(item.get("execution_mode") or "") != "backend_direct":
continue
if str(item.get("template_id") or "") != template_id:
continue
return McpServerConfigPayload.model_validate(item).model_dump(mode="json")
return None
@staticmethod
def _preferred_backend_direct_template_ids(*, query_kind: str) -> list[str]:
normalized = str(query_kind or "unknown").strip().lower() or "unknown"
if normalized == "model":
return ["microsoft_learn", "duckduckgo"]
if normalized in {"generic_web", "unknown", "company", "news", "market"}:
return ["duckduckgo"]
return []
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 == "duckduckgo":
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 == "duckduckgo":
return McpCommandPreview(
add_command="无需本机命令。保存后由 PMFlow 后端直接使用 DuckDuckGo 搜索运行时。",
verify_command="点击“重新校验”后,PMFlow 会从后端检查 DuckDuckGo 搜索运行时是否可用。",
expected_output=(
"校验成功后,你会看到后端可用工具,例如 ddg-text-search、"
"ddg-news-search。"
),
)
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()
if template.id == "duckduckgo":
return await DuckDuckGoSearchRuntime().probe()
return {
"status": "error",
"checked_at": now_cn_naive().isoformat(),
"message": f"模板 {template.id} 暂未实现后端直连 runtime。",
"tool_names": [],
}