"""物料 MCP 服务:给模型查询组件目录、物料默认配置和字段定义 只监听本机,由应用按 mcp_servers.json 拉起,不对外暴露;查询逻辑在 app.resources.material_fields, 动作校验在主进程直接调用同一套逻辑,这里只是给模型使用的入口 单独运行:uv run python -X utf8 -m mcp_servers.material_server(端口取 PORT,默认 8201) """ import asyncio import os from typing import Annotated, Any from loguru import logger from mcp.server.fastmcp import FastMCP from pydantic import Field from pymongo import AsyncMongoClient from app.config import settings from app.resources import material_fields from app.schemas.component_resource import ComponentCatalog from app.services.material_mongo import MongoMaterialRepository mcp = FastMCP( "materials", host="127.0.0.1", # 由应用拉起时通过 PORT 传入,单独运行时默认 8201 port=int(os.environ.get("PORT") or 8201), streamable_http_path="/mcp", # 工具只读且无会话状态,每次调用独立处理 stateless_http=True, ) # 启动时从 MongoDB 载入,与主进程读取同一份目录;返回结果带版本,主进程据此核对 _catalog: ComponentCatalog | None = None ComponentType = Annotated[ str, Field(description="物料类型,即大屏节点的 type,例如 bar-chart") ] def _require_catalog() -> ComponentCatalog: if _catalog is None: raise RuntimeError("物料目录未载入") return _catalog @mcp.tool() async def list_components() -> dict[str, Any]: """列出可以新增的组件:key、type、名称、用途和默认宽高""" return material_fields.list_components(_require_catalog()) @mcp.tool() async def get_component_materials( component_types: Annotated[ list[ComponentType], Field(description="要使用的物料类型,可一次传多个", min_length=1), ], ) -> dict[str, Any]: """获取物料的完整默认配置(schema)和使用说明""" return material_fields.get_materials(_require_catalog(), component_types) @mcp.tool() async def list_field_groups(component_type: ComponentType) -> dict[str, Any]: """列出物料的字段分组:每组管什么、包含哪些字段名称 先看分组目录,再用 get_fields 查看相关分组的完整字段定义 """ return material_fields.list_field_groups(_require_catalog(), component_type) @mcp.tool() async def get_fields( component_type: ComponentType, group_keys: Annotated[ list[str], Field(description="要查看的分组 key,可一次传多个", min_length=1) ], ) -> dict[str, Any]: """查看分组内的完整字段定义:路径、含义、值类型、可选值、取值范围和默认值 修改组件属性只能使用这里返回的字段路径,值须符合字段的类型和取值约束 """ return material_fields.get_fields(_require_catalog(), component_type, group_keys) @mcp.tool() async def search_fields( component_type: ComponentType, query: Annotated[ str, Field(description="用户对属性的说法,例如“名字”“柱子颜色”“数值标签”") ], ) -> dict[str, Any]: """按名称、别名、路径和说明搜索字段,按相关度返回完整字段定义 用户的说法对不上分组时使用;多个结果时根据字段说明判断用户指的是哪一个 """ return material_fields.search_fields(_require_catalog(), component_type, query) async def _load_catalog() -> ComponentCatalog: if not settings.mongodb_uri: raise SystemExit("未配置 MONGODB_URI,物料字段服务无法载入目录") client = AsyncMongoClient(settings.mongodb_uri, serverSelectionTimeoutMS=5000) try: catalog = await MongoMaterialRepository(client[settings.mongodb_db]).load_all() finally: await client.close() if catalog is None: raise SystemExit("materials 集合尚未导入,物料字段服务无法载入目录") return catalog if __name__ == "__main__": _catalog = asyncio.run(_load_catalog()) logger.info( "物料字段服务已载入 {} 个物料,版本 {}", len(_catalog.components), _catalog.version, ) mcp.run(transport="streamable-http")