429 lines
20 KiB
Python
429 lines
20 KiB
Python
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": [],
|
||
}
|