Lu Xianghui пре 3 недеља
родитељ
комит
a3004740ab

+ 1 - 0
.gitignore

@@ -12,3 +12,4 @@ build/
 .env
 .env.*
 !.env.example
+tests/

+ 226 - 107
data_collector_mcp/bacnet_server.py

@@ -1,8 +1,8 @@
 from __future__ import annotations
 
-from typing import Any, NotRequired, TypedDict
+from typing import Annotated, Any
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict, Field
 
 from .collector_api import (
     create_bacnet_devices as api_create_bacnet_devices,
@@ -18,84 +18,208 @@ from .gateway_api import (
 from .mcp_app import mcp
 
 
+JsonScalar = str | int | float | bool | None
+
+ProjectKeyParam = Annotated[str, Field(description="项目标识,来自 project.list 返回的 project_key。")]
+BacnetIpParam = Annotated[str, Field(description="BACnet/IP 设备地址。")]
+BacnetDeviceIdParam = Annotated[int, Field(description="BACnet 设备对象实例号,范围 0..4194303。")]
+BacnetPortParam = Annotated[int, Field(description="BACnet/IP UDP 端口,默认 47808。")]
+BacnetObjectTypeParam = Annotated[
+    str,
+    Field(description="BACnet 对象类型;支持 AnalogInput、analogInput、analog-input 等常见写法。"),
+]
+BacnetObjectIdParam = Annotated[int, Field(description="BACnet 对象实例号,范围 0..4194303。")]
+
+
+class FlexibleModel(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+
 class BacnetDeviceCreateItem(BaseModel):
-    name: str
-    ip: str
-    bacnet_device_id: int
-    port: int = 47808
-    bacnet_net: int = 0
-    asp_ip: str = ""
-    timeout: int = 3
-    is_persistent: bool = False
-    group_id: int = 0
-    alarm_interval: int = 90
-    collect_interval: int = 5
-
-
-class BacnetPointSpec(TypedDict):
-    object_type: str
-    object_id: int
-    name: NotRequired[str]
-    description: NotRequired[str]
+    name: str = Field(description="设备名称。")
+    ip: str = Field(description="BACnet/IP 设备地址。")
+    bacnet_device_id: int = Field(description="BACnet 设备对象实例号,范围 0..4194303。")
+    port: int = Field(default=47808, description="BACnet/IP UDP 端口。")
+    bacnet_net: int = Field(default=0, description="BACnet 网络号;ASP 设备通常为 0。")
+    asp_ip: str = Field(default="", description="RP 设备关联 ASP 地址;ASP 设备通常为空。")
+    timeout: int = Field(default=3, description="连接超时时间。")
+    is_persistent: bool = Field(default=False, description="是否持久化连接。")
+    group_id: int = Field(default=0, description="设备分组 id。")
+    alarm_interval: int = Field(default=90, description="告警间隔,单位秒。")
+    collect_interval: int = Field(default=5, description="采集周期,单位秒。")
+
+
+class BacnetPointSpec(BaseModel):
+    object_type: BacnetObjectTypeParam
+    object_id: BacnetObjectIdParam
+    name: str | None = Field(default=None, description="点位名称;读取当前值时可不传。")
+    description: str | None = Field(default=None, description="点位描述;读取当前值时可不传。")
 
 
 class BacnetPointCreateItem(BaseModel):
-    device_id: int
-    object_type: str
-    object_id: int
-    name: str = ""
-    object_name: str = ""
-    point_id: str = ""
-    priority: int | None = None
-    units: str = ""
-    value_type: int = 0
-    scale_ratio: float = 1
-    value_offset: float = 0
-    group_id: int = 0
-    invalid_values: str = ""
-    valid_range_start: float | None = None
-    valid_range_end: float | None = None
-    describe: str = ""
+    device_id: int = Field(description="所属 BACnet 设备 id,来自 collector.device_list 或设备创建结果。")
+    object_type: BacnetObjectTypeParam
+    object_id: BacnetObjectIdParam
+    name: str = Field(default="", description="点位名称;为空时可由 object_name 补齐。")
+    object_name: str = Field(default="", description="BACnet 对象名;为空时可由 name 补齐。")
+    point_id: str = Field(default="", description="外部点位编码。")
+    priority: int | None = Field(default=None, description="写值优先级;读取点位通常为 null。")
+    value_type: int = Field(
+        default=0,
+        description="BACnet PresentValue 类型;常见值:1=Boolean,2=Unsigned,3=Signed,4=Real,5=Double,6=Enumerated。",
+    )
+    scale_ratio: float = Field(default=1, description="缩放系数。")
+    value_offset: float = Field(default=0, description="值偏移。")
+    group_id: int = Field(default=0, description="点位分组 id。")
+    invalid_values: str = Field(default="", description="无效值列表,多个值用逗号分隔。")
+    valid_range_start: float | None = Field(default=None, description="合法范围最小值。")
+    valid_range_end: float | None = Field(default=None, description="合法范围最大值。")
+    describe: str = Field(default="", description="点位描述。")
+
+
+class GatewayBacnetDevice(FlexibleModel):
+    device_type: str | None = Field(default=None, description="设备类型,通常为 BACnet/IP。")
+    ip: str | None = Field(default=None, description="BACnet/IP 设备地址。")
+    port: int | None = Field(default=None, description="BACnet/IP UDP 端口。")
+    bacnet_device_id: int | None = Field(default=None, description="BACnet 设备对象实例号。")
+
+
+class GatewayBacnetPoint(FlexibleModel):
+    name: str | None = Field(default=None, description="点位名称;点位搜索时由 object-name 读取。")
+    description: str | None = Field(default=None, description="点位描述;点位搜索时由 description 读取。")
+    object_type: str | None = Field(default=None, description="BACnet 对象类型。")
+    object_id: int | None = Field(default=None, description="BACnet 对象实例号。")
+    present_value: JsonScalar = Field(default=None, description="当前值;读取 BACnet present-value 得到。")
+
+
+class GatewayBacnetPointsData(FlexibleModel):
+    device: GatewayBacnetDevice | None = Field(default=None, description="本次读取或搜索的 BACnet 设备信息。")
+    points: list[GatewayBacnetPoint] = Field(default_factory=list, description="BACnet 点位列表。")
+
+
+class BacnetGatewayPointsOutput(FlexibleModel):
+    code: int | str | None = Field(default=None, description="网关状态码;0 表示成功。")
+    msg: str | None = Field(default=None, description="网关状态说明或错误信息。")
+    data: GatewayBacnetPointsData | None = Field(default=None, description="BACnet 点位读取或搜索结果。")
+
+
+class BacnetBbmdConfig(FlexibleModel):
+    bbmd_ip: str | None = Field(default=None, description="BBMD 地址。")
+    bbmd_port: int | None = Field(default=None, description="BBMD UDP 端口。")
+    ttl: int | None = Field(default=None, description="Foreign Device 注册 TTL,单位秒。")
+    timeout: int | None = Field(default=None, description="收集 I-Am 响应的等待时间,单位秒。")
+    low_limit: int | None = Field(default=None, description="Who-Is 设备号下限。")
+    high_limit: int | None = Field(default=None, description="Who-Is 设备号上限。")
+
+
+class BacnetDiscoveredDevice(FlexibleModel):
+    bacnet_device_id: int | None = Field(default=None, description="发现的 BACnet 设备对象实例号。")
+    ip: str | None = Field(default=None, description="发现的 BACnet/IP 设备地址。")
+    port: int | None = Field(default=None, description="发现的 BACnet/IP UDP 端口。")
+    max_apdu: int | None = Field(default=None, description="设备支持的最大 APDU 长度。")
+    segmentation: str | None = Field(default=None, description="设备分段能力。")
+    vendor_id: int | None = Field(default=None, description="BACnet 厂商 id。")
+
+
+class BacnetBbmdWhoisData(FlexibleModel):
+    bbmd: BacnetBbmdConfig | None = Field(default=None, description="本次 BBMD Who-Is 使用的配置。")
+    devices: list[BacnetDiscoveredDevice] = Field(default_factory=list, description="发现的 BACnet 设备列表。")
+
+
+class BacnetBbmdWhoisOutput(FlexibleModel):
+    code: int | str | None = Field(default=None, description="网关状态码;0 表示成功。")
+    msg: str | None = Field(default=None, description="网关状态说明或错误信息。")
+    data: BacnetBbmdWhoisData | None = Field(default=None, description="BBMD Who-Is 设备发现结果。")
+
+
+class CollectorBaseOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="汇采状态码;0 表示成功。")
+    state_info: str | None = Field(default=None, description="汇采状态说明。")
+
+
+class BatchError(FlexibleModel):
+    index: int | None = Field(default=None, description="输入数组中的下标。")
+    name: str | None = Field(default=None, description="输入项名称。")
+    stage: str | None = Field(default=None, description="失败阶段。")
+    error: str | None = Field(default=None, description="失败原因。")
+
+
+class BacnetDeviceCreateSummary(FlexibleModel):
+    total: int | None = Field(default=None, description="输入设备总数。")
+    created: int | None = Field(default=None, description="汇采创建设备成功数量。")
+    matched: int | None = Field(default=None, description="创建后从设备列表匹配到设备 id 的数量。")
+    failed: int | None = Field(default=None, description="失败数量。")
+
+
+class BacnetDeviceCreateResult(FlexibleModel):
+    index: int | None = Field(default=None, description="输入 devices 数组中的下标。")
+    name: str | None = Field(default=None, description="设备名称。")
+    device_id: int | None = Field(default=None, description="匹配到的汇采设备 id。")
+    create_response: dict[str, Any] | None = Field(default=None, description="汇采创建设备接口原始响应。")
+    matched_device: dict[str, Any] | None = Field(default=None, description="设备列表中匹配到的设备详情。")
+
+
+class BacnetDeviceCreateOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="批量创建状态;0 表示全部成功,1 表示存在失败。")
+    summary: BacnetDeviceCreateSummary | None = Field(default=None, description="批量创建设备汇总。")
+    results: list[BacnetDeviceCreateResult] = Field(default_factory=list, description="每个设备的创建和匹配结果。")
+    errors: list[BatchError] = Field(default_factory=list, description="失败项列表。")
+
+
+class BacnetPointCreateSummary(FlexibleModel):
+    total: int | None = Field(default=None, description="输入点位总数。")
+    success: int | None = Field(default=None, description="汇采创建点位成功数量。")
+    failed: int | None = Field(default=None, description="失败数量。")
+
+
+class BacnetPointCreateResult(FlexibleModel):
+    index: int | None = Field(default=None, description="输入 points 数组中的下标。")
+    name: str | None = Field(default=None, description="点位名称。")
+    device_id: int | None = Field(default=None, description="所属 BACnet 设备 id。")
+    response: dict[str, Any] | None = Field(default=None, description="汇采创建点位接口原始响应。")
+
+
+class BacnetPointCreateOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="批量创建状态;0 表示全部成功,1 表示存在失败。")
+    summary: BacnetPointCreateSummary | None = Field(default=None, description="批量创建点位汇总。")
+    results: list[BacnetPointCreateResult] = Field(default_factory=list, description="每个点位的创建结果。")
+    errors: list[BatchError] = Field(default_factory=list, description="失败项列表。")
 
 
 @mcp.tool(
     name="bacnet.point_collect_test",
     description=(
-        "采集网关-读取 BACnet/IP 设备点位的值,只需传 project_key、ip、"
-        "bacnet_device_id 和 points;port 默认 47808。points 每项包含 object_type、object_id;"
-        "object_type 支持 AnalogInput、analogInput、analog-input"
-        "不需要传name字段"
-        "响应 data.points[].present_value 为当前值,code=0 表示成功。"
+        "采集网关-读取 BACnet/IP 设备点位的当前值。"
     ),
 )
 def bacnet_point_collect_test(
-    project_key: str,
-    ip: str,
-    bacnet_device_id: int,
-    points: list[BacnetPointSpec],
-    port: int = 47808,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    ip: BacnetIpParam,
+    bacnet_device_id: BacnetDeviceIdParam,
+    points: Annotated[list[BacnetPointSpec], Field(description="要读取的 BACnet 点位列表。")],
+    port: BacnetPortParam = 47808,
+) -> BacnetGatewayPointsOutput:
     return api_bacnet_point_collect_test(
         project_key,
         ip=ip,
         bacnet_device_id=bacnet_device_id,
         port=port,
-        points=points,
+        points=[_dump_model_or_dict(item) for item in points],
     )
 
 
 @mcp.tool(
     name="bacnet.point_search",
     description=(
-        "采集网关-搜索 BACnet/IP 设备点位。必须传 project_key、ip、bacnet_device_id;port 默认 47808。"
+        "采集网关-搜索 BACnet/IP 设备点位"
     ),
 )
 def bacnet_point_search(
-    project_key: str,
-    ip: str,
-    bacnet_device_id: int,
-    port: int = 47808,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    ip: BacnetIpParam,
+    bacnet_device_id: BacnetDeviceIdParam,
+    port: BacnetPortParam = 47808,
+) -> BacnetGatewayPointsOutput:
     return api_bacnet_point_search(
         project_key,
         ip=ip,
@@ -107,56 +231,52 @@ def bacnet_point_search(
 @mcp.tool(
     name="bacnet.bbmd_whois",
     description=(
-        "采集网关-查询设备列表,通过 BBMD 执行 BACnet/IP Who-Is 设备发现。只需传 project_key。"
-        "响应 data.devices 为发现的 BACnet 设备,"
+        "采集网关-通过 BBMD 执行 BACnet/IP Who-Is 设备发现。"
     ),
 )
-def bacnet_bbmd_whois(project_key: str) -> dict[str, Any]:
+def bacnet_bbmd_whois(project_key: ProjectKeyParam) -> BacnetBbmdWhoisOutput:
     return api_bacnet_bbmd_whois(project_key)
 
 
 @mcp.tool(
     name="collector.bacnet_device_create",
     description=(
-        "汇采-批量创建 BACnet 设备。devices 每项必须传 name、ip、bacnet_device_id;"
-        "返回批量创建结果和对应的设备 id"
+        "汇采-批量创建 BACnet 设备。创建后会读取设备列表并匹配返回设备 id。"
     ),
 )
 def collector_bacnet_device_create(
-    project_key: str,
-    devices: list[BacnetDeviceCreateItem],
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    devices: Annotated[list[BacnetDeviceCreateItem], Field(description="要创建的 BACnet 设备列表。")],
+) -> BacnetDeviceCreateOutput:
     return api_create_bacnet_devices(project_key, [_dump_model_or_dict(item) for item in devices])
 
 
 @mcp.tool(
     name="collector.bacnet_device_edit",
     description=(
-        "汇采-编辑 BACnet 设备。必须传 ori_id、name、ip、bacnet_device_id;"
-        "ori_id是设备id,bacnet_device_id是设备号"
-        "编辑前设备不能处于已连接状态,"
+        "汇采-编辑 BACnet 设备。编辑前设备不能处于已连接状态;"
         "若已连接请先调用 collector.device_disconnect 且 device_type=bacnet。"
     ),
 )
 def collector_bacnet_device_edit(
-    project_key: str,
-    ori_id: int,
-    name: str,
-    ip: str,
-    bacnet_device_id: int,
-    port: int = 47808,
-    bacnet_net: int = 0,
-    asp_ip: str = "",
-    is_persistent: bool = False,
-    device_group_id: int = 0,
-    timeout: int = 3,
-    alarm_interval: int = 90,
-    collect_interval: int = 5,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    id: Annotated[int, Field(description="要编辑的原汇采设备 id。")],
+    name: Annotated[str, Field(description="设备名称。")],
+    ip: BacnetIpParam,
+    bacnet_device_id: BacnetDeviceIdParam,
+    port: BacnetPortParam = 47808,
+    bacnet_net: Annotated[int, Field(description="BACnet 网络号;ASP 设备通常为 0。")] = 0,
+    asp_ip: Annotated[str, Field(description="RP 设备关联 ASP 地址;ASP 设备通常为空。")] = "",
+    is_persistent: Annotated[bool, Field(description="是否持久化连接。")] = False,
+    device_group_id: Annotated[int, Field(description="设备分组 id。")] = 0,
+    timeout: Annotated[int, Field(description="连接超时时间,默认3秒。")] = 3,
+    alarm_interval: Annotated[int, Field(description="告警间隔,默认90秒。")] = 90,
+    collect_interval: Annotated[int, Field(description="采集周期,默认5秒。")] = 5,
+) -> CollectorBaseOutput:
     return api_edit_bacnet_device(
         project_key,
         {
-            "ori_id": ori_id,
+            "ori_id": id,
             "name": name,
             "ip": ip,
             "bacnet_device_id": bacnet_device_id,
@@ -175,54 +295,53 @@ def collector_bacnet_device_edit(
 @mcp.tool(
     name="collector.bacnet_point_create",
     description=(
-        "汇采-批量创建 BACnet 采集点位。points 每项必须传 device_id、object_type、object_id, name,"
-        "object_type 支持 AnalogInput、analogInput、analog-input"
+        "汇采-批量创建 BACnet 采集点位。"
     ),
 )
 def collector_bacnet_point_create(
-    project_key: str,
-    points: list[BacnetPointCreateItem],
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    points: Annotated[list[BacnetPointCreateItem], Field(description="要创建的 BACnet 采集点位列表。")],
+) -> BacnetPointCreateOutput:
     return api_create_bacnet_points(project_key, [_dump_model_or_dict(item) for item in points])
 
 
 @mcp.tool(
     name="collector.bacnet_point_edit",
     description=(
-        "汇采-编辑 BACnet 采集点位。必须传 ori_id、object_type、object_id,name"
-        "ori_id 是设备id,object_type 支持 AnalogInput、analogInput、analog-input"
+        "汇采-编辑 BACnet 采集点位。对象类型会在调用汇采接口前规范化。"
     ),
 )
 def collector_bacnet_point_edit(
-    project_key: str,
-    ori_id: int,
-    object_type: str,
-    object_id: int,
-    name: str = "",
-    object_name: str = "",
-    point_id: str = "",
-    priority: int | None = None,
-    units: str = "",
-    value_type: int = 0,
-    scale_ratio: float = 1,
-    value_offset: float = 0,
-    group_id: int = 0,
-    invalid_values: str = "",
-    valid_range_start: float | None = None,
-    valid_range_end: float | None = None,
-    describe: str = "",
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    id: Annotated[int, Field(description="要编辑的原采集点位 id,对应 collector.device_points 返回的点位 id。")],
+    object_type: BacnetObjectTypeParam,
+    object_id: BacnetObjectIdParam,
+    name: Annotated[str, Field(description="点位名称;为空时可由 object_name 补齐。")] = "",
+    object_name: Annotated[str, Field(description="BACnet 对象名;为空时可由 name 补齐。")] = "",
+    point_id: Annotated[str, Field(description="外部点位编码。")] = "",
+    priority: Annotated[int | None, Field(description="写值优先级;读取点位通常为 null。")] = None,
+    value_type: Annotated[
+        int,
+        Field(description="BACnet PresentValue 类型;常见值:1=Boolean,2=Unsigned,3=Signed,4=Real,5=Double,6=Enumerated。"),
+    ] = 0,
+    scale_ratio: Annotated[float, Field(description="缩放系数。")] = 1,
+    value_offset: Annotated[float, Field(description="值偏移。")] = 0,
+    group_id: Annotated[int, Field(description="点位分组 id。")] = 0,
+    invalid_values: Annotated[str, Field(description="无效值列表,多个值用逗号分隔。")] = "",
+    valid_range_start: Annotated[float | None, Field(description="合法范围最小值。")] = None,
+    valid_range_end: Annotated[float | None, Field(description="合法范围最大值。")] = None,
+    describe: Annotated[str, Field(description="点位描述。")] = "",
+) -> CollectorBaseOutput:
     return api_edit_bacnet_point(
         project_key,
         {
-            "ori_id": ori_id,
+            "id": id,
             "name": name,
             "object_type": object_type,
             "object_id": object_id,
             "object_name": object_name,
             "point_id": point_id,
             "priority": priority,
-            "units": units,
             "value_type": value_type,
             "scale_ratio": scale_ratio,
             "value_offset": value_offset,

+ 3 - 6
data_collector_mcp/collector_api.py

@@ -162,8 +162,7 @@ def _normalize_s7_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
 
 def _normalize_s7_device_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
     normalized = _normalize_s7_device_payload(payload)
-    if normalized.get("id") is None:
-        normalized["id"] = _require_present(normalized, "ori_id")
+    _require_present(normalized, "id")
     normalized["id"] = _normalize_positive_int(normalized["id"], "payload.id")
     normalized.pop("ori_id", None)
     return normalized
@@ -225,8 +224,7 @@ def _normalize_s7_point_payload(payload: dict[str, Any], *, require_device_id: b
 
 def _normalize_s7_point_edit_payload(payload: dict[str, Any]) -> dict[str, Any]:
     normalized = _normalize_s7_point_payload(payload)
-    if normalized.get("id") is None:
-        normalized["id"] = _require_present(normalized, "ori_id")
+    _require_present(normalized, "id")
     normalized["id"] = _normalize_positive_int(normalized["id"], "payload.id")
     normalized.pop("ori_id", None)
     return normalized
@@ -320,8 +318,7 @@ def _normalize_bacnet_point_edit_payload(payload: dict[str, Any]) -> dict[str, A
         BACNET_SPEC.point_defaults,
         _normalize_bacnet_point_payload(payload, require_device_id=False),
     )
-    normalized["id"] = _normalize_positive_int(_require_present(normalized, "ori_id"), "payload.ori_id")
-    normalized.pop("ori_id", None)
+    normalized["id"] = _normalize_positive_int(_require_present(normalized, "id"), "payload.id")
     normalized.pop("device_id", None)
     return normalized
 

+ 107 - 34
data_collector_mcp/common_server.py

@@ -1,6 +1,8 @@
 from __future__ import annotations
 
-from typing import Any
+from typing import Annotated, Any
+
+from pydantic import BaseModel, ConfigDict, Field
 
 from .auth import load_projects_config
 from .collector_api import (
@@ -12,16 +14,95 @@ from .collector_api import (
 from .mcp_app import mcp
 
 
+JsonScalar = str | int | float | bool | None
+
+ProjectKeyParam = Annotated[str, Field(description="项目标识,来自 project.list 返回的 project_key。")]
+DeviceIdParam = Annotated[int, Field(description="汇采设备 id,来自 collector.device_list 返回的设备对象。")]
+DeviceTypeParam = Annotated[
+    str,
+    Field(description="设备协议类型,可传 modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"),
+]
+DeviceGroupIdParam = Annotated[int, Field(description="设备点位分组 id,默认 0 表示查询设备下默认分组。")]
+
+
+class FlexibleModel(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+
+class ProjectListItem(BaseModel):
+    project_key: str = Field(description="项目标识,用于其他采集工具的 project_key 参数。")
+    project_name: str = Field(description="项目显示名称。")
+
+
+class ProjectListOutput(BaseModel):
+    projects: list[ProjectListItem] = Field(description="当前 MCP 服务可用的已启用项目列表。")
+    total: int = Field(description="可用项目数量。")
+
+
+class CollectorBaseOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="汇采状态码;0 表示成功。")
+    state_info: str | None = Field(default=None, description="汇采状态说明。")
+
+
+class CollectorDeviceItem(FlexibleModel):
+    id: int | None = Field(default=None, description="设备 id,可用于连接、断开和查询点位。")
+    name: str | None = Field(default=None, description="设备名称。")
+    type: str | None = Field(default=None, description="设备类型;devicegroup 表示设备分组。")
+    device_type: int | str | None = Field(default=None, description="设备子类型或协议内设备类型。")
+    ip: str | None = Field(default=None, description="设备地址。")
+    port: int | None = Field(default=None, description="设备端口。")
+    num_points: int | None = Field(default=None, description="设备点位数量;num_points=true 时返回。")
+    status: int | None = Field(default=None, description="连接状态:1=未连接,2=已连接,3=连接异常。")
+    running_status: int | None = Field(default=None, description="运行状态:0=未采集,1=采集中,2=采集异常。")
+    group_id: int | None = Field(default=None, description="所属设备分组 id。")
+    groups: list["CollectorDeviceItem"] | None = Field(default=None, description="设备分组下的设备列表。")
+
+
+class CollectorDeviceListOutput(CollectorBaseOutput):
+    devices: list[CollectorDeviceItem] = Field(default_factory=list, description="设备列表;type=devicegroup 时继续检查 groups。")
+
+
+class CollectorDeviceConnectData(FlexibleModel):
+    status: int | None = Field(default=None, description="操作后的设备连接状态:1=未连接,2=已连接,3=连接异常。")
+    running_status: int | None = Field(default=None, description="操作后的设备运行状态:0=未采集,1=采集中,2=采集异常。")
+    msg: str | None = Field(default=None, description="连接或断开失败时的错误信息;成功时通常为空。")
+
+
+class CollectorDeviceConnectOutput(CollectorBaseOutput):
+    data: CollectorDeviceConnectData | None = Field(default=None, description="设备连接或断开后的状态。")
+
+
+class CollectorDevicePointItem(FlexibleModel):
+    id: int | None = Field(default=None, description="采集点位 id,可用于对应协议的点位编辑工具。")
+    node_id: str | None = Field(default=None, description="采集节点 id。")
+    name: str | None = Field(default=None, description="点位名称。")
+    point_id: str | None = Field(default=None, description="外部点位编码。")
+    data_type: str | None = Field(default=None, description="数据类型。")
+    present_value: JsonScalar = Field(default=None, description="当前内存中的点位最新值。")
+    status: int | None = Field(default=None, description="点位状态:0=未采集,1=采集正常,2=采集异常。")
+    group_id: int | None = Field(default=None, description="所属点位分组 id。")
+    created_time: str | None = Field(default=None, description="创建时间。")
+    updated_time: str | None = Field(default=None, description="更新时间。")
+
+
+class CollectorDevicePointsData(FlexibleModel):
+    point: list[CollectorDevicePointItem] = Field(default_factory=list, description="设备下的采集点位列表。")
+    total: int | None = Field(default=None, description="点位总数。")
+
+
+class CollectorDevicePointsOutput(CollectorBaseOutput):
+    data: CollectorDevicePointsData | None = Field(default=None, description="设备点位查询结果。")
+
+
 @mcp.tool(
     name="project.list",
     title="Project List",
     description=(
-        "List enabled 汇采 projects available to this MCP service, including "
-        "project_key and project_name. Call this first to choose project_key."
+        "列出当前 MCP 服务可用的已启用采集项目。使用其他采集工具前,应先调用本工具选择 project_key。"
     ),
     tags={"project", "list"},
 )
-def project_list() -> dict[str, Any]:
+def project_list() -> ProjectListOutput:
     projects = load_projects_config()
     result = [
         {
@@ -38,67 +119,59 @@ def project_list() -> dict[str, Any]:
 @mcp.tool(
     name="collector.device_list",
     description=(
-        "汇采-返回目前所有设备及其详情信息。返回内容中的设备字段包括不限于 name 设备名、"
-        "type 设备类型、num_points 点位数量、running_status 运行状态:"
-        "0 已停止,1 正常,2 异常。type 为 devicegroup 时表示设备分组,"
-        "需要继续检查 groups 下的设备。"
-        "num_points 默认 false;只有需要统计设备或点位分组下的点位数量时才传 true。"
+        "汇采-返回目前所有设备及其详情信息。type=devicegroup 时表示设备分组,需要继续检查 groups 下的设备。"
     ),
 )
-def collector_device_list(project_key: str, num_points: bool = False) -> dict[str, Any]:
+def collector_device_list(
+    project_key: ProjectKeyParam,
+    num_points: Annotated[bool, Field(description="是否返回设备点位数量;默认 false。")]= False,
+) -> CollectorDeviceListOutput:
     return api_list_devices(project_key, num_points=num_points)
 
 
 @mcp.tool(
     name="collector.device_connect",
     description=(
-        "汇采-连接设备。连接成功不代表正在采集,设备采集状态请看响应 data.running_status"
-        "device_id 是设备列表中的设备 id;device_type 可传modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
-        "响应data字段下常见状态:"
-        "data.status 1=未连接、2=已连接、3=连接异常;"
-        "data.running_status 0=未采集、1=采集中、2=采集异常。"
+        "汇采-连接设备。连接成功不代表正在采集,设备采集状态请看响应 data.running_status。"
     ),
 )
 def collector_device_connect(
-    project_key: str,
-    device_id: int,
-    device_type: str,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    device_id: DeviceIdParam,
+    device_type: DeviceTypeParam,
+) -> CollectorDeviceConnectOutput:
     return api_connect_device(project_key, device_id=device_id, device_type=device_type)
 
 
 @mcp.tool(
     name="collector.device_disconnect",
     description=(
-        "汇采-断开设备。用于停止指定设备连接/采集相关状态,使设备回到未连接或空闲状态"
-        "device_id 是设备列表中的设备 id;device_type 可传modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
-        "响应data字段下常见状态:"
-        "data.status 1=未连接、2=已连接、3=连接异常;"
-        "data.running_status 0=未采集、1=采集中、2=采集异常。"
+        "汇采-断开设备。用于停止指定设备连接或采集相关状态,使设备回到未连接或空闲状态。"
     ),
 )
 def collector_device_disconnect(
-    project_key: str,
-    device_id: int,
-    device_type: str = "modbus",
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    device_id: DeviceIdParam,
+    device_type: DeviceTypeParam = "modbus",
+) -> CollectorDeviceConnectOutput:
     return api_disconnect_device(project_key, device_id=device_id, device_type=device_type)
 
 
 @mcp.tool(
     name="collector.device_points",
     description=(
-        "汇采-查询设备下的所有点位。device_id 是设备id, device_type 可传modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。"
-        "status 0=未采集、1=采集正常、2=采集异常, present_value 是当前值"
+        "汇采-查询设备下的所有采集点位。返回的点位 id 可用于对应协议的点位编辑工具。"
     ),
 )
 def collector_device_points(
-    project_key: str,
-    device_id: int,
-    device_type: str,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    device_id: DeviceIdParam,
+    device_type: DeviceTypeParam,
+    group_id: DeviceGroupIdParam = 0,
+) -> CollectorDevicePointsOutput:
     return api_list_device_points(
         project_key,
         device_id=device_id,
         device_type=device_type,
+        group_id=group_id,
     )

+ 237 - 160
data_collector_mcp/modbus_server.py

@@ -1,8 +1,8 @@
 from __future__ import annotations
 
-from typing import Any, NotRequired, TypedDict
+from typing import Annotated, Any
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict, Field
 
 from .collector_api import (
     create_modbus_devices as api_create_modbus_devices,
@@ -17,82 +17,193 @@ from .gateway_api import (
 from .mcp_app import mcp
 
 
+JsonScalar = str | int | float | bool | None
+
+ProjectKeyParam = Annotated[str, Field(description="项目标识,来自 project.list 返回的 project_key。")]
+ModbusIpParam = Annotated[str, Field(description="Modbus TCP 设备地址。")]
+ModbusPortParam = Annotated[int, Field(description="Modbus TCP 端口。")]
+ModbusSlaveIdParam = Annotated[int, Field(description="Modbus 从站 ID。")]
+ModbusDeviceTypeParam = Annotated[str, Field(description="网关设备类型,默认 ModbusTCP。")]
+ModbusWordByteOrderParam = Annotated[str, Field(description="字节/字顺序,可选 ABCD、BADC、CDAB、DCBA。")]
+ModbusAddressBaseParam = Annotated[int, Field(description="地址基准或地址偏移,默认 0。")]
+ModbusFunctionCodeParam = Annotated[int, Field(description="功能码:1=线圈,2=离散输入,3=保持寄存器,4=输入寄存器。")]
+ModbusDataTypeParam = Annotated[
+    str,
+    Field(description="数据类型;支持 bool、int16、uint16、int32、uint32、int64、uint64、float32、float64 及常见别名。"),
+]
+
+
+class FlexibleModel(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+
 class ModbusDeviceCreateItem(BaseModel):
-    name: str
-    ip: str
-    port: int
-    slave_id: int
-    byte_order: int
-    word_order: int
-    address_base: int
-    serial_port: str = ""
-    device_type: int = 1
-    timeout: int = 3
-    is_persistent: bool = False
-    baud_rate: int = 0
-    data_bit: int = 0
-    parity: int = 0
-    stop_bit: int = 0
-    mode: int = 0
-    retry_times: int = 0
-    group_id: int = 0
-    alarm_interval: int = 90
-    collect_interval: int = 5
+    name: str = Field(description="设备名称。")
+    ip: str = Field(description="Modbus TCP/UDP 设备地址;RTU 设备可为空。")
+    port: int = Field(description="Modbus TCP/UDP 端口;RTU 设备可为 0。")
+    slave_id: int = Field(description="Modbus 从站 ID。")
+    byte_order: int = Field(description="字节顺序:1=Big Endian,2=Small Endian。")
+    word_order: int = Field(description="字顺序:1=Big Endian,2=Small Endian。")
+    address_base: int = Field(description="地址基准;会转换为汇采接口 address_offset。")
+    serial_port: str = Field(default="", description="RTU 串口名。")
+    device_type: int = Field(default=1, description="协议类型:1=TCP,2=RTU,3=UDP,4=RTU OVER TCP,5=RTU OVER UDP。")
+    timeout: int = Field(default=3, description="连接超时时间。")
+    is_persistent: bool = Field(default=False, description="是否持久化连接。")
+    baud_rate: int = Field(default=0, description="RTU 波特率。")
+    data_bit: int = Field(default=0, description="RTU 数据位。")
+    parity: int = Field(default=0, description="RTU 校验位。")
+    stop_bit: int = Field(default=0, description="RTU 停止位。")
+    mode: int = Field(default=0, description="连接模式。")
+    retry_times: int = Field(default=0, description="重试次数。")
+    group_id: int = Field(default=0, description="设备分组 id。")
+    alarm_interval: int = Field(default=90, description="告警间隔,单位秒。")
+    collect_interval: int = Field(default=5, description="采集周期,单位秒。")
 
 
 class ModbusPointCreateItem(BaseModel):
-    device_id: int
-    name: str
-    address: int
-    type: str
-    func_code: int
-    register_type: str = ""
-    point_id: str = ""
-    scale_ratio: float = 1
-    value_offset: float = 0
-    group_id: int = 0
-    invalid_values: str = ""
-    valid_range_start: float | None = None
-    valid_range_end: float | None = None
-    bit: int = 0
-    describe: str = ""
-
-
-class ModbusRawReadSpec(TypedDict):
-    function_code: int
-    address: int
-    quantity: int
+    device_id: int = Field(description="所属 Modbus 设备 id,来自 collector.device_list 或设备创建结果。")
+    name: str = Field(description="点位名称。")
+    address: int = Field(description="寄存器地址。")
+    type: ModbusDataTypeParam
+    func_code: int | None = Field(default=3, description="功能码或寄存器类型:1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。")
+    register_type: str = Field(default="", description="寄存器类型;可用 coil、discrete_input、holding_register、input_register。")
+    point_id: str = Field(default="", description="外部点位编码。")
+    scale_ratio: float = Field(default=1, description="缩放系数。")
+    value_offset: float = Field(default=0, description="值偏移。")
+    group_id: int = Field(default=0, description="点位分组 id。")
+    invalid_values: str = Field(default="", description="无效值列表,多个值用逗号分隔。")
+    valid_range_start: float | None = Field(default=None, description="合法范围最小值。")
+    valid_range_end: float | None = Field(default=None, description="合法范围最大值。")
+    bit: int = Field(default=0, description="位索引;用于从寄存器中取指定 bit。")
+    describe: str = Field(default="", description="点位描述。")
+
+
+class ModbusRawReadSpec(BaseModel):
+    function_code: ModbusFunctionCodeParam
+    address: int = Field(description="起始地址。")
+    quantity: int = Field(description="读取数量,范围 1..125。")
+
+
+class ModbusPointCollectSpec(FlexibleModel):
+    function_code: int | None = Field(default=None, description="功能码或寄存器类型:1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。")
+    func_code: int | None = Field(default=None, description="功能码或寄存器类型:1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。")
+    address: int = Field(description="寄存器地址。")
+    type: ModbusDataTypeParam
+    bit: int | None = Field(default=None, description="位索引;用于读取 bool 位。")
+    name: str | None = Field(default=None, description="点位名称;测试读取时可不传。")
+
+
+class GatewayBaseOutput(FlexibleModel):
+    code: int | str | None = Field(default=None, description="网关状态码;0 表示成功。")
+    msg: str | None = Field(default=None, description="网关状态说明或错误信息。")
+
+
+class GatewayCommunicationData(FlexibleModel):
+    communication: list[str] = Field(default_factory=list, description="本次请求期间捕获的通信报文或通信过程记录。")
+
+
+class ModbusRawReadOutput(GatewayBaseOutput):
+    data: GatewayCommunicationData | None = Field(default=None, description="Modbus 原始读取结果。")
+
+
+class ModbusGatewayPoint(FlexibleModel):
+    name: str | None = Field(default=None, description="点位名称。")
+    address: int | None = Field(default=None, description="寄存器地址。")
+    type: str | None = Field(default=None, description="数据类型。")
+    value: JsonScalar = Field(default=None, description="转换后的点位值。")
+
+
+class ModbusPointCollectData(GatewayCommunicationData):
+    points: list[ModbusGatewayPoint] = Field(default_factory=list, description="转换后的 Modbus 点位值列表。")
+
+
+class ModbusPointCollectOutput(GatewayBaseOutput):
+    data: ModbusPointCollectData | None = Field(default=None, description="Modbus 点位读取结果。")
+
+
+class CollectorBaseOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="汇采状态码;0 表示成功。")
+    state_info: str | None = Field(default=None, description="汇采状态说明。")
+    data: Any = Field(default=None, description="汇采接口返回数据。")
+
+
+class CollectorPointEditOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="汇采状态码;0 表示成功。")
+    state_info: str | None = Field(default=None, description="汇采状态说明。")
+
+
+class BatchError(FlexibleModel):
+    index: int | None = Field(default=None, description="输入数组中的下标。")
+    name: str | None = Field(default=None, description="输入项名称。")
+    stage: str | None = Field(default=None, description="失败阶段。")
+    error: str | None = Field(default=None, description="失败原因。")
+
+
+class DeviceCreateSummary(FlexibleModel):
+    total: int | None = Field(default=None, description="输入设备总数。")
+    created: int | None = Field(default=None, description="汇采创建设备成功数量。")
+    matched: int | None = Field(default=None, description="创建后从设备列表匹配到设备 id 的数量。")
+    failed: int | None = Field(default=None, description="失败数量。")
+
+
+class DeviceCreateResult(FlexibleModel):
+    index: int | None = Field(default=None, description="输入 devices 数组中的下标。")
+    name: str | None = Field(default=None, description="设备名称。")
+    device_id: int | None = Field(default=None, description="匹配到的汇采设备 id。")
+    create_response: dict[str, Any] | None = Field(default=None, description="汇采创建设备接口原始响应。")
+    matched_device: dict[str, Any] | None = Field(default=None, description="设备列表中匹配到的设备详情。")
+
+
+class ModbusDeviceCreateOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="批量创建状态;0 表示全部成功,1 表示存在失败。")
+    summary: DeviceCreateSummary | None = Field(default=None, description="批量创建设备汇总。")
+    results: list[DeviceCreateResult] = Field(default_factory=list, description="每个设备的创建和匹配结果。")
+    errors: list[BatchError] = Field(default_factory=list, description="失败项列表。")
+
+
+class PointCreateSummary(FlexibleModel):
+    total: int | None = Field(default=None, description="输入点位总数。")
+    success: int | None = Field(default=None, description="汇采创建点位成功数量。")
+    failed: int | None = Field(default=None, description="失败数量。")
+
+
+class PointCreateResult(FlexibleModel):
+    index: int | None = Field(default=None, description="输入 points 数组中的下标。")
+    name: str | None = Field(default=None, description="点位名称。")
+    device_id: int | None = Field(default=None, description="所属 Modbus 设备 id。")
+    response: dict[str, Any] | None = Field(default=None, description="汇采创建点位接口原始响应。")
+
+
+class ModbusPointCreateOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="批量创建状态;0 表示全部成功,1 表示存在失败。")
+    summary: PointCreateSummary | None = Field(default=None, description="批量创建点位汇总。")
+    results: list[PointCreateResult] = Field(default_factory=list, description="每个点位的创建结果。")
+    errors: list[BatchError] = Field(default_factory=list, description="失败项列表。")
 
 
 @mcp.tool(
     name="modbus.raw_read",
     description=(
-        "采集网关-通过Modbus TCP 读取原始数据。"
-        "device_type 设备类型,默认 ModbusTCP;word_byte_order 默认 ABCD,可选 ABCD、BADC、CDAB、DCBA;"
-        "address_base 地址偏移,默认 0。"
-        "read 必须包含 function_code、address、quantity;function_code: "
-        "1=Read Coils/线圈,2=Read Discrete Inputs/离散输入,"
-        "3=Read Holding Registers/保持寄存器,4=Read Input Registers/输入寄存器;"
-        "quantity 范围 1..125。"
-        "不要传name字段"
+        "采集网关-通过 Modbus TCP 读取原始数据,只返回通信报文,不解析业务值。"
+        "如需转换后的点位值,请使用 modbus.point_collect_test。"
     ),
 )
 def modbus_raw_read(
-    project_key: str,
-    ip: str,
-    port: int,
-    slave_id: int,
-    read: ModbusRawReadSpec,
-    device_type: str = "ModbusTCP",
-    word_byte_order: str = "ABCD",
-    address_base: int = 0,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    ip: ModbusIpParam,
+    port: ModbusPortParam,
+    slave_id: ModbusSlaveIdParam,
+    read: Annotated[ModbusRawReadSpec, Field(description="原始读取参数。")],
+    device_type: ModbusDeviceTypeParam = "ModbusTCP",
+    word_byte_order: ModbusWordByteOrderParam = "ABCD",
+    address_base: ModbusAddressBaseParam = 0,
+) -> ModbusRawReadOutput:
     return api_modbus_raw_read(
         project_key,
         ip=ip,
         port=port,
         slave_id=slave_id,
-        read=dict(read),
+        read=_dump_model_or_dict(read),
         device_type=device_type,
         word_byte_order=word_byte_order,
         address_base=address_base,
@@ -102,28 +213,25 @@ def modbus_raw_read(
 @mcp.tool(
     name="modbus.point_collect_test",
     description=(
-        "通过采集网关读取 Modbus TCP 点位的数据"
-        "寄存器类型可选:1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,"
-        "3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。"
-        "word_byte_order 可选 ABCD、BADC、CDAB、DCBA"
+        "采集网关-读取 Modbus TCP 点位并转换为业务值。"
     ),
 )
 def modbus_point_collect_test(
-    project_key: str,
-    ip: str,
-    port: int,
-    slave_id: int,
-    points: list[dict[str, Any]],
-    device_type: str = "ModbusTCP",
-    word_byte_order: str = "ABCD",
-    address_base: int = 0,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    ip: ModbusIpParam,
+    port: ModbusPortParam,
+    slave_id: ModbusSlaveIdParam,
+    points: Annotated[list[ModbusPointCollectSpec], Field(description="要读取的 Modbus 点位列表。")],
+    device_type: ModbusDeviceTypeParam = "ModbusTCP",
+    word_byte_order: ModbusWordByteOrderParam = "ABCD",
+    address_base: ModbusAddressBaseParam = 0,
+) -> ModbusPointCollectOutput:
     return api_modbus_point_collect_test(
         project_key,
         ip=ip,
         port=port,
         slave_id=slave_id,
-        points=points,
+        points=[_dump_model_or_dict(item) for item in points],
         device_type=device_type,
         word_byte_order=word_byte_order,
         address_base=address_base,
@@ -133,61 +241,52 @@ def modbus_point_collect_test(
 @mcp.tool(
     name="collector.modbus_device_create",
     description=(
-        "汇采-批量创建 Modbus 设备。"
-        "默认参数: type=modbus, timeout=3, is_persistent=false, group_id=0, "
-        "alarm_interval=90, collect_interval=5, retry_times=0。"
-        "address_base 地址偏移/address_offset。"
-        "byte_order: 1=Big Endian, 2=Small Endian。word_order: 1=Big Endian, 2=Small Endian。"
-        "word_byte_order 映射: ABCD=>1/1, BADC=>2/1, CDAB=>1/2, DCBA=>2/2。"
+        "汇采-批量创建 Modbus 设备。创建后会读取设备列表并匹配返回设备 id。"
+        "word_byte_order 映射到 byte_order/word_order:ABCD=>1/1,BADC=>2/1,CDAB=>1/2,DCBA=>2/2。"
     ),
 )
 def collector_modbus_device_create(
-    project_key: str,
-    devices: list[ModbusDeviceCreateItem],
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    devices: Annotated[list[ModbusDeviceCreateItem], Field(description="要创建的 Modbus 设备列表。")],
+) -> ModbusDeviceCreateOutput:
     return api_create_modbus_devices(project_key, [_dump_model_or_dict(item) for item in devices])
 
 
 @mcp.tool(
     name="collector.modbus_device_edit",
     description=(
-        "汇采-编辑 Modbus 设备。必须传 ori_id 原设备 id、name、slave_id、"
-        "word_order、byte_order、ip 和 port;"
-        "编辑前设备不能处于已连接状态;若已连接,请先调用 collector.device_disconnect。"
-        "默认参数: ip='', port=0, serial_port='', timeout=3, is_persistent=false, "
-        "baud_rate=0, data_bit=0, parity=0, stop_bit=0, mode=0, address_offset=0, "
-        "retry_times=0, device_group_id=0, alarm_interval=90, collect_interval=5。"
-        "byte_order: 1=Big Endian, 2=Small Endian;word_order: 1=Big Endian, 2=Small Endian。"
+        "汇采-编辑 Modbus 设备。编辑前设备不能处于已连接状态;"
+        "若已连接,请先调用 collector.device_disconnect。"
     ),
 )
 def collector_modbus_device_edit(
-    project_key: str,
-    ori_id: int,
-    name: str,
-    device_type: int,
-    slave_id: int,
-    byte_order: int,
-    word_order: int,
-    ip: str = "",
-    port: int = 0,
-    serial_port: str = "",
-    timeout: int = 3,
-    is_persistent: bool = False,
-    baud_rate: int = 0,
-    data_bit: int = 0,
-    parity: int = 0,
-    stop_bit: int = 0,
-    mode: int = 0,
-    address_offset: int = 0,
-    retry_times: int = 0,
-    device_group_id: int = 0,
-    alarm_interval: int = 90,
-    collect_interval: int = 5,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    id: Annotated[int, Field(description="要编辑的原汇采设备 id。")],
+    name: Annotated[str, Field(description="设备名称。")],
+    device_type: Annotated[int, Field(description="协议类型:1=TCP,2=RTU,3=UDP,4=RTU OVER TCP,5=RTU OVER UDP。")],
+    slave_id: ModbusSlaveIdParam,
+    byte_order: Annotated[int, Field(description="字节顺序:1=Big Endian,2=Small Endian。")],
+    word_order: Annotated[int, Field(description="字顺序:1=Big Endian,2=Small Endian。")],
+    ip: Annotated[str, Field(description="Modbus TCP/UDP 设备地址;RTU 设备可为空。")]= "",
+    port: Annotated[int, Field(description="Modbus TCP/UDP 端口;RTU 设备可为 0。")]= 0,
+    serial_port: Annotated[str, Field(description="RTU 串口名。")]= "",
+    timeout: Annotated[int, Field(description="连接超时时间。")]= 3,
+    is_persistent: Annotated[bool, Field(description="是否持久化连接。")]= False,
+    baud_rate: Annotated[int, Field(description="RTU 波特率。")]= 0,
+    data_bit: Annotated[int, Field(description="RTU 数据位。")]= 0,
+    parity: Annotated[int, Field(description="RTU 校验位。")]= 0,
+    stop_bit: Annotated[int, Field(description="RTU 停止位。")]= 0,
+    mode: Annotated[int, Field(description="连接模式。")]= 0,
+    address_offset: Annotated[int, Field(description="地址偏移。")]= 0,
+    retry_times: Annotated[int, Field(description="重试次数。")]= 0,
+    device_group_id: Annotated[int, Field(description="设备分组 id。")]= 0,
+    alarm_interval: Annotated[int, Field(description="告警间隔,单位秒。")]= 90,
+    collect_interval: Annotated[int, Field(description="采集周期,单位秒。")]= 5,
+) -> CollectorBaseOutput:
     return api_edit_modbus_device(
         project_key,
         {
-            "ori_id": ori_id,
+            "ori_id": id,
             "name": name,
             "device_type": device_type,
             "ip": ip,
@@ -215,20 +314,13 @@ def collector_modbus_device_edit(
 @mcp.tool(
     name="collector.modbus_point_create",
     description=(
-        "汇采-批量创建 Modbus 采集点位。"
-        "points 每项必须传 device_id、name 名称、address 寄存器地址、"
-        "type 数据类型,以及 func_code 寄存器类型。"
-        "func_code: 1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,"
-        "3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。"
-        "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
-        "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
-        "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
+        "汇采-批量创建 Modbus 采集点位。数据类型和寄存器类型会在调用汇采接口前规范化。"
     ),
 )
 def collector_modbus_point_create(
-    project_key: str,
-    points: list[ModbusPointCreateItem],
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    points: Annotated[list[ModbusPointCreateItem], Field(description="要创建的 Modbus 采集点位列表。")],
+) -> ModbusPointCreateOutput:
     return api_create_modbus_points(project_key, [_dump_model_or_dict(item) for item in points])
 
 
@@ -241,40 +333,28 @@ def _dump_model_or_dict(item: Any) -> dict[str, Any]:
 @mcp.tool(
     name="collector.modbus_point_edit",
     description=(
-        "汇采-编辑 Modbus 采集点位。调用 "
-        "必须传 ori_id 原点位 id、name 名称、address 寄存器地址、type 数据类型,"
-        "以及 func_code 寄存器类型。"
-        "ori_id 对应 collector.device_points 返回的 data.point[].id。"
-        "默认参数: point_id='', scale_ratio=1, value_offset=0, group_id=0, "
-        "invalid_values='', valid_range_start=null, valid_range_end=null, bit=0。"
-        "func_code: 1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,"
-        "3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。"
-        "register_type 可用 coil、discrete_input、holding_register、input_register。"
-        "数据类型应使用汇采类型: bool, int16, uint16, int32, uint32, int64, uint64, float32, float64。"
-        "常见点表类型映射: BOOL=>bool, SHORT=>int16, WORD=>uint16, LONG=>int32, "
-        "DWORD=>uint32, FLOAT/REAL=>float32, DOUBLE=>float64, LONGLONG=>int64, QWORD=>uint64。"
+        "汇采-编辑 Modbus 采集点位。数据类型和寄存器类型会在调用汇采接口前规范化。"
     ),
 )
 def collector_modbus_point_edit(
-    project_key: str,
-    ori_id: int,
-    name: str,
-    address: int,
-    data_type: str,
-    func_code: int = 0,
-    register_type: str = "",
-    point_id: str = "",
-    scale_ratio: float = 1,
-    value_offset: float = 0,
-    group_id: int = 0,
-    invalid_values: str = "",
-    valid_range_start: float | None = None,
-    valid_range_end: float | None = None,
-    bit: int = 0,
-    describe: str = "",
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    id: Annotated[int, Field(description="要编辑的原采集点位 id,对应 collector.device_points 返回的点位 id。")],
+    name: Annotated[str, Field(description="点位名称。")],
+    address: Annotated[int, Field(description="寄存器地址。")],
+    data_type: ModbusDataTypeParam,
+    func_code: Annotated[int, Field(description="功能码或寄存器类型:1=Read Coils/线圈/0x,2=Read Discrete Inputs/离散输入/1x,3=Read Holding Registers/保持寄存器/4x,4=Read Input Registers/输入寄存器/3x。")],
+    point_id: Annotated[str, Field(description="外部点位编码。")]= "",
+    scale_ratio: Annotated[float, Field(description="缩放系数。")]= 1,
+    value_offset: Annotated[float, Field(description="值偏移。")]= 0,
+    group_id: Annotated[int, Field(description="点位分组 id。")]= 0,
+    invalid_values: Annotated[str, Field(description="无效值列表,多个值用逗号分隔。")]= "",
+    valid_range_start: Annotated[float | None, Field(description="合法范围最小值。")]= None,
+    valid_range_end: Annotated[float | None, Field(description="合法范围最大值。")]= None,
+    bit: Annotated[int, Field(description="位索引;用于从寄存器中取指定 bit。")]= 0,
+    describe: Annotated[str, Field(description="点位描述。")]= "",
+) -> CollectorPointEditOutput:
     payload: dict[str, Any] = {
-        "ori_id": ori_id,
+        "ori_id": id,
         "name": name,
         "address": address,
         "type": data_type,
@@ -288,8 +368,5 @@ def collector_modbus_point_edit(
         "bit": bit,
         "describe": describe,
     }
-    if func_code:
-        payload["func_code"] = func_code
-    else:
-        payload["register_type"] = register_type
+    payload["func_code"] = func_code
     return api_edit_modbus_point(project_key, payload)

+ 240 - 135
data_collector_mcp/s7_server.py

@@ -1,8 +1,8 @@
 from __future__ import annotations
 
-from typing import Any, NotRequired, TypedDict
+from typing import Annotated, Any
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict, Field
 
 from .collector_api import (
     create_s7_devices as api_create_s7_devices,
@@ -18,64 +18,200 @@ from .gateway_api import (
 from .mcp_app import mcp
 
 
+JsonScalar = str | int | float | bool | None
+
+ProjectKeyParam = Annotated[str, Field(description="项目标识,来自 project.list 返回的 project_key。")]
+S7IpParam = Annotated[str, Field(description="PLC 地址。")]
+S7RockParam = Annotated[int, Field(description="机架号。")]
+S7SlotParam = Annotated[int, Field(description="槽号。")]
+S7PortParam = Annotated[int, Field(description="S7 TCP 端口,默认 102。")]
+S7DeviceTypeParam = Annotated[str, Field(description="网关设备类型,可用 S7-1200、S7-1500、S7-Smart200。")]
+S7TsapConnTypeParam = Annotated[str | None, Field(description="TSAP 连接类型,可用 PG、OP、BASIC;不传时默认 PG。")]
+S7DataTypeParam = Annotated[
+    str,
+    Field(description="数据类型;支持 bool、uint8、int8、uint16、int16、uint32、int32、float32、float64 及常见别名。"),
+]
+
+
+class FlexibleModel(BaseModel):
+    model_config = ConfigDict(extra="allow")
+
+
 class S7DeviceCreateItem(BaseModel):
-    name: str
-    ip: str
-    rock: int
-    slot: int
-    port: int = 102
-    device_type: int = 1
-    tsap_conn_type: str | None = None
-    is_persistent: bool = False
-    device_group_id: int = 0
-    group_id: int | None = None
-    timeout: int = 3
-    alarm_interval: int = 90
-    collect_interval: int = 5
-
-
-class S7PointCreateItem(TypedDict):
-    device_id: int
-    name: str
-    address: str
-    data_type: NotRequired[str]
-    register_type: NotRequired[int]
-    point_id: NotRequired[str]
-    scale_ratio: NotRequired[float]
-    value_offset: NotRequired[float]
-    group_id: NotRequired[int]
-    invalid_values: NotRequired[str]
-    valid_range_start: NotRequired[float | None]
-    valid_range_end: NotRequired[float | None]
-    describe: NotRequired[str]
+    name: str = Field(description="设备名称。")
+    ip: str = Field(description="PLC 地址。")
+    rock: int = Field(description="机架号。")
+    slot: int = Field(description="槽号。")
+    port: int = Field(default=102, description="S7 TCP 端口。")
+    device_type: int = Field(default=1, description="设备类型:1=S7-1200,2=S7-1500,3=S7-Smart200。")
+    tsap_conn_type: str | None = Field(default=None, description="TSAP 连接类型,可用 PG、OP、BASIC;不传时默认 PG。")
+    is_persistent: bool = Field(default=False, description="是否持久化连接。")
+    device_group_id: int = Field(default=0, description="设备分组 id。")
+    group_id: int | None = Field(default=None, description="设备分组 id;会转换为 device_group_id。")
+    timeout: int = Field(default=3, description="连接超时时间。")
+    alarm_interval: int = Field(default=90, description="告警间隔,单位秒。")
+    collect_interval: int = Field(default=5, description="采集周期,单位秒。")
+
+
+class S7PointCreateItem(BaseModel):
+    device_id: int = Field(description="所属 S7 设备 id,来自 collector.device_list 或设备创建结果。")
+    name: str = Field(description="点位名称。")
+    address: str = Field(description="S7 地址;DB 非 bool 通常为 db.byte,DB bool 为 db.byte.bit。")
+    data_type: str | None = Field(default=None, description="数据类型;也可用 type 指定。")
+    type: str | None = Field(default=None, description="数据类型别名;会转换为 data_type。")
+    register_type: int = Field(description="寄存器区域:1=I,2=Q,3=M,4=DB,5=V,6=AI。")
+    point_id: str = Field(default="", description="外部点位编码。")
+    scale_ratio: float = Field(default=1, description="缩放系数。")
+    value_offset: float = Field(default=0, description="值偏移。")
+    group_id: int = Field(default=0, description="点位分组 id。")
+    invalid_values: str = Field(default="", description="无效值列表,多个值用逗号分隔。")
+    valid_range_start: float | None = Field(default=None, description="合法范围最小值。")
+    valid_range_end: float | None = Field(default=None, description="合法范围最大值。")
+    describe: str = Field(default="", description="点位描述。")
+
+
+class S7RawReadSpec(FlexibleModel):
+    area: str = Field(description="读取区域,可用 DB、M、I、Q、V。")
+    start: int = Field(description="起始字节地址。")
+    size: int = Field(description="读取字节数。")
+    db: int | None = Field(default=None, description="DB 块编号;area=DB 时使用。")
+
+
+class S7PointCollectSpec(FlexibleModel):
+    area: str = Field(description="读取区域,可用 DB、M、I、Q、V。")
+    start: int = Field(description="起始字节地址。")
+    type: S7DataTypeParam
+    db: int | None = Field(default=None, description="DB 块编号;area=DB 时使用。")
+    bit: int | None = Field(default=None, description="bool 点读取的位索引,范围 0..7。")
+    name: str | None = Field(default=None, description="点位名称;测试读取时可不传。")
+
+
+class GatewayBaseOutput(FlexibleModel):
+    code: int | str | None = Field(default=None, description="网关状态码;0 表示成功。")
+    msg: str | None = Field(default=None, description="网关状态说明或错误信息。")
+
+
+class S7CommunicationData(FlexibleModel):
+    communication: list[str] = Field(default_factory=list, description="本次请求期间的连接、读取和断开过程记录。")
+
+
+class S7RawReadOutput(GatewayBaseOutput):
+    data: S7CommunicationData | None = Field(default=None, description="S7 原始字节读取结果。")
+
+
+class S7GatewayPoint(FlexibleModel):
+    name: str | None = Field(default=None, description="点位名称。")
+    area: str | None = Field(default=None, description="读取区域。")
+    start: int | None = Field(default=None, description="起始字节地址。")
+    type: str | None = Field(default=None, description="数据类型。")
+    value: JsonScalar = Field(default=None, description="转换后的点位值。")
+
+
+class S7PointCollectData(S7CommunicationData):
+    points: list[S7GatewayPoint] = Field(default_factory=list, description="转换后的 S7 点位值列表。")
+
+
+class S7PointCollectOutput(GatewayBaseOutput):
+    data: S7PointCollectData | None = Field(default=None, description="S7 点位读取结果。")
+
+
+class S7ConnectScanItem(FlexibleModel):
+    rock: int | None = Field(default=None, description="可用机架号。")
+    slot: int | None = Field(default=None, description="可用槽号。")
+    tsap_conn_type: str | None = Field(default=None, description="可用 TSAP 连接类型。")
+    device_type: str | None = Field(default=None, description="可用设备类型。")
+
+
+class S7ConnectScanData(FlexibleModel):
+    available: list[S7ConnectScanItem] = Field(default_factory=list, description="扫描到的可用连接组合。")
+
+
+class S7ConnectScanOutput(GatewayBaseOutput):
+    data: S7ConnectScanData | None = Field(default=None, description="S7 连接扫描结果。")
+
+
+class CollectorBaseOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="汇采状态码;0 表示成功。")
+    state_info: str | None = Field(default=None, description="汇采状态说明。")
+    data: Any = Field(default=None, description="汇采接口返回数据。")
+
+
+class CollectorPointEditOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="汇采状态码;0 表示成功。")
+    state_info: str | None = Field(default=None, description="汇采状态说明。")
+
+
+class BatchError(FlexibleModel):
+    index: int | None = Field(default=None, description="输入数组中的下标。")
+    name: str | None = Field(default=None, description="输入项名称。")
+    stage: str | None = Field(default=None, description="失败阶段。")
+    error: str | None = Field(default=None, description="失败原因。")
+
+
+class DeviceCreateSummary(FlexibleModel):
+    total: int | None = Field(default=None, description="输入设备总数。")
+    created: int | None = Field(default=None, description="汇采创建设备成功数量。")
+    matched: int | None = Field(default=None, description="创建后从设备列表匹配到设备 id 的数量。")
+    failed: int | None = Field(default=None, description="失败数量。")
+
+
+class DeviceCreateResult(FlexibleModel):
+    index: int | None = Field(default=None, description="输入 devices 数组中的下标。")
+    name: str | None = Field(default=None, description="设备名称。")
+    device_id: int | None = Field(default=None, description="匹配到的汇采设备 id。")
+    create_response: dict[str, Any] | None = Field(default=None, description="汇采创建设备接口原始响应。")
+    matched_device: dict[str, Any] | None = Field(default=None, description="设备列表中匹配到的设备详情。")
+
+
+class S7DeviceCreateOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="批量创建状态;0 表示全部成功,1 表示存在失败。")
+    summary: DeviceCreateSummary | None = Field(default=None, description="批量创建设备汇总。")
+    results: list[DeviceCreateResult] = Field(default_factory=list, description="每个设备的创建和匹配结果。")
+    errors: list[BatchError] = Field(default_factory=list, description="失败项列表。")
+
+
+class PointCreateSummary(FlexibleModel):
+    total: int | None = Field(default=None, description="输入点位总数。")
+    success: int | None = Field(default=None, description="汇采创建点位成功数量。")
+    failed: int | None = Field(default=None, description="失败数量。")
+
+
+class PointCreateResult(FlexibleModel):
+    index: int | None = Field(default=None, description="输入 points 数组中的下标。")
+    name: str | None = Field(default=None, description="点位名称。")
+    device_id: int | None = Field(default=None, description="所属 S7 设备 id。")
+    response: dict[str, Any] | None = Field(default=None, description="汇采创建点位接口原始响应。")
+
+
+class S7PointCreateOutput(FlexibleModel):
+    state: int | str | None = Field(default=None, description="批量创建状态;0 表示全部成功,1 表示存在失败。")
+    summary: PointCreateSummary | None = Field(default=None, description="批量创建点位汇总。")
+    results: list[PointCreateResult] = Field(default_factory=list, description="每个点位的创建结果。")
+    errors: list[BatchError] = Field(default_factory=list, description="失败项列表。")
 
 
 @mcp.tool(
     name="s7.raw_read",
     description=(
-        "采集网关-对 Siemens S7 TCP 设备执行原始字节读取。"
-        "device_type 为设备类型,可传S7-1200、 S7-1200、S7-Smart200。"
-        "tsap_conn_type 可选 PG、OP、BASIC"
-        "read 必须包含 area、start、size;"
-        "area 可用 DB、M、I、Q、V"
+        "采集网关-对 Siemens S7 TCP 设备执行原始字节读取,不解析业务值。"
     ),
 )
 def s7_raw_read(
-    project_key: str,
-    ip: str,
-    read: dict[str, Any],
-    rock: int = 0,
-    slot: int = 1,
-    device_type: str = "S7-1200",
-    port: int = 102,
-    tsap_conn_type: str | None = None,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    ip: S7IpParam,
+    read: Annotated[S7RawReadSpec, Field(description="原始字节读取参数。")],
+    rock: S7RockParam = 0,
+    slot: S7SlotParam = 1,
+    device_type: S7DeviceTypeParam = "S7-1200",
+    port: S7PortParam = 102,
+    tsap_conn_type: S7TsapConnTypeParam = None,
+) -> S7RawReadOutput:
     return api_s7_raw_read(
         project_key,
         ip=ip,
         rock=rock,
         slot=slot,
-        read=read,
+        read=_dump_model_or_dict(read),
         device_type=device_type,
         port=port,
         tsap_conn_type=tsap_conn_type,
@@ -85,32 +221,25 @@ def s7_raw_read(
 @mcp.tool(
     name="s7.point_collect_test",
     description=(
-        "采集网关-读取 Siemens S7 TCP 点位数据"
-        "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
-        "device_type 为设备类型,可传S7-1200、 S7-1200、S7-Smart200。"
-        "tsap_conn_type 可传 PG、OP、BASIC;"
-        "points 为网关读取格式的点位数组,每个点位必须包含 "
-        "area、start、type;area 可用 DB、M、I、Q、V;"
-        "type 可用 bool、byte、int8、int16、uint16、int32、uint32、int64、uint64、"
-        "float32、float64;bool 点可传 bit 读取指定 0..7 位,非 bool 点不能传 bit。"
+        "采集网关-读取 Siemens S7 TCP 点位并转换为业务值。"
     ),
 )
 def s7_point_collect_test(
-    project_key: str,
-    ip: str,
-    rock: int,
-    slot: int,
-    points: list[dict[str, Any]],
-    device_type: str = "S7-1200",
-    port: int = 102,
-    tsap_conn_type: str | None = None,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    ip: S7IpParam,
+    rock: S7RockParam,
+    slot: S7SlotParam,
+    points: Annotated[list[S7PointCollectSpec], Field(description="要读取的 S7 点位列表。")],
+    device_type: S7DeviceTypeParam = "S7-1200",
+    port: S7PortParam = 102,
+    tsap_conn_type: S7TsapConnTypeParam = None,
+) -> S7PointCollectOutput:
     return api_s7_point_collect_test(
         project_key,
         ip=ip,
         rock=rock,
         slot=slot,
-        points=points,
+        points=[_dump_model_or_dict(item) for item in points],
         device_type=device_type,
         port=port,
         tsap_conn_type=tsap_conn_type,
@@ -120,10 +249,10 @@ def s7_point_collect_test(
 @mcp.tool(
     name="s7.connect_scan",
     description=(
-        "采集网关-扫描 Siemens S7 TCP 设备可用连接组合。只需要传 PLC 的 ip"
+        "采集网关-扫描 Siemens S7 TCP 设备可用连接组合。"
     ),
 )
-def s7_connect_scan(project_key: str, ip: str) -> dict[str, Any]:
+def s7_connect_scan(project_key: ProjectKeyParam, ip: S7IpParam) -> S7ConnectScanOutput:
     return api_s7_connect_scan(project_key, ip=ip)
 
 
@@ -137,52 +266,44 @@ def _resolve_collector_s7_tsap_conn_type(device_type: int, tsap_conn_type: str |
 @mcp.tool(
     name="collector.s7_device_create",
     description=(
-        "汇采-批量创建 S7 设备。"
-        "type=s7。接收project_key和devices列表,"
-        "devices 每项必须传 name 名称、ip IP 地址、rock 机架号(轨道号)、slot 槽号;"
-        "port 默认 102,device_type 默认 1。device_type 必须使用数字枚举,"
-        "1=S7-1200, 2=S7-1500, 3=S7-Smart200。tsap_conn_type 可选 PG、OP、BASIC;"
-        "device_group_id=0, timeout=3, alarm_interval=90, collect_interval=5。"
-        "全部设备创建调用完成后会内部调用设备列表并匹配每个设备 id。"
+        "汇采-批量创建 S7 设备。创建后会读取设备列表并匹配返回设备 id。"
     ),
 )
 def collector_s7_device_create(
-    project_key: str,
-    devices: list[S7DeviceCreateItem],
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    devices: Annotated[list[S7DeviceCreateItem], Field(description="要创建的 S7 设备列表。")],
+) -> S7DeviceCreateOutput:
     return api_create_s7_devices(project_key, [_dump_model_or_dict(item) for item in devices])
 
 
 @mcp.tool(
     name="collector.s7_device_edit",
     description=(
-        "汇采-编辑 S7 设备。"
-        "必须传 ori_id 原设备 id、name、ip、rock、slot;port 默认 102,device_type 默认 1,"
-        "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
+        "汇采-编辑 S7 设备。只有明确需要 OP/BASIC 时才显式传 tsap_conn_type。"
         "编辑前设备不能处于已连接状态;若已连接,请先调用 "
         "collector.device_disconnect,并传 device_type=s7。"
     ),
 )
 def collector_s7_device_edit(
-    project_key: str,
-    ori_id: int,
-    name: str,
-    ip: str,
-    rock: int,
-    slot: int,
-    port: int = 102,
-    device_type: int = 1,
-    tsap_conn_type: str | None = None,
-    is_persistent: bool = False,
-    device_group_id: int = 0,
-    timeout: int = 3,
-    alarm_interval: int = 90,
-    collect_interval: int = 5,
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    id: Annotated[int, Field(description="要编辑的原汇采设备 id。")],
+    name: Annotated[str, Field(description="设备名称。")],
+    ip: S7IpParam,
+    rock: S7RockParam,
+    slot: S7SlotParam,
+    port: S7PortParam = 102,
+    device_type: Annotated[int, Field(description="设备类型:1=S7-1200,2=S7-1500,3=S7-Smart200。")]= 1,
+    tsap_conn_type: Annotated[str | None, Field(description="TSAP 连接类型,可用 PG、OP、BASIC;不传时默认 PG。")]= None,
+    is_persistent: Annotated[bool, Field(description="是否持久化连接。")]= False,
+    device_group_id: Annotated[int, Field(description="设备分组 id。")]= 0,
+    timeout: Annotated[int, Field(description="连接超时时间。")]= 3,
+    alarm_interval: Annotated[int, Field(description="告警间隔,单位秒。")]= 90,
+    collect_interval: Annotated[int, Field(description="采集周期,单位秒。")]= 5,
+) -> CollectorBaseOutput:
     return api_edit_s7_device(
         project_key,
         {
-            "ori_id": ori_id,
+            "id": id,
             "name": name,
             "ip": ip,
             "rock": rock,
@@ -202,23 +323,16 @@ def collector_s7_device_edit(
 @mcp.tool(
     name="collector.s7_point_create",
     description=(
-        "汇采-批量创建 S7 采集点位。"
-        "points 每项必须传 device_id、name、address、data_type、"
-        "register_type 寄存器类型: 1=I输入区, 2=Q输出区, 3=M存储区, 4=DB数据块, 5=V区, 6=AI模拟输入"
-        "data_type 可用 bool, uint8, int8, uint16, int16, uint32, int32, float32, float64。"
-        "常见点表类型映射: BOOL=>bool, FLOAT/REAL=>float32, SHORT/INT=>int16, WORD=>uint16, "
-        "DWORD=>uint32, DINT/LONG=>int32, DOUBLE/LREAL=>float64。"
+        "汇采-批量创建 S7 采集点位。数据类型会在调用汇采接口前规范化。"
         "address 地址格式:I/Q/M/V 布尔点通常为 byte.bit,如 10.2;DB 布尔点为 db.byte.bit,"
         "如 1.10.2;非布尔点 I/Q/M/V 通常为 byte 地址,如 10,DB 为 db.byte,如 1.10。"
-        "DB/DBD/DBW/DBX=>DB。BOOL 点把地址列和位地址列合并为 address='byte.bit';"
-        "DB BOOL 合并为 address='db.byte.bit';非 BOOL 使用字节地址,DB 非 BOOL 使用 'db.byte'。"
     ),
 )
 def collector_s7_point_create(
-    project_key: str,
-    points: list[S7PointCreateItem],
-) -> dict[str, Any]:
-    return api_create_s7_points(project_key, points)
+    project_key: ProjectKeyParam,
+    points: Annotated[list[S7PointCreateItem], Field(description="要创建的 S7 采集点位列表。")],
+) -> S7PointCreateOutput:
+    return api_create_s7_points(project_key, [_dump_model_or_dict(item) for item in points])
 
 
 def _dump_model_or_dict(item: Any) -> dict[str, Any]:
@@ -230,34 +344,28 @@ def _dump_model_or_dict(item: Any) -> dict[str, Any]:
 @mcp.tool(
     name="collector.s7_point_edit",
     description=(
-        "汇采-编辑 S7 采集点位。"
-        "必须传 ori_id 原点位 id、device_id 所属设备 id、name、address、data_type,"
-        "以及 register_type 。"
-        "scale_ratio=1, value_offset=0, group_Id=0, invalid_values='', valid_range_start=null, "
-        "valid_range_end=null。register_type 寄存器类型: 1=I输入区, 2=Q输出区, 3=M存储区, 4=DB数据块, 5=V区, 6=AI模拟输入。"
-        "data_type 可用 bool, uint8, int8, uint16, int16, uint32, int32, float32, float64。"
+        "汇采-编辑 S7 采集点位。数据类型会在调用汇采接口前规范化。"
     ),
 )
 def collector_s7_point_edit(
-    project_key: str,
-    ori_id: int,
-    device_id: int,
-    name: str,
-    address: str,
-    data_type: str,
-    register_type: int = 0,
-    register_area: str = "",
-    point_id: str = "",
-    scale_ratio: float = 1,
-    value_offset: float = 0,
-    group_id: int = 0,
-    invalid_values: str = "",
-    valid_range_start: float | None = None,
-    valid_range_end: float | None = None,
-    describe: str = "",
-) -> dict[str, Any]:
+    project_key: ProjectKeyParam,
+    id: Annotated[int, Field(description="要编辑的原采集点位 id,对应 collector.device_points 返回的点位 id。")],
+    device_id: Annotated[int, Field(description="所属 S7 设备 id。")],
+    name: Annotated[str, Field(description="点位名称。")],
+    address: Annotated[str, Field(description="S7 地址;DB 非 bool 通常为 db.byte,DB bool 为 db.byte.bit。")],
+    data_type: S7DataTypeParam,
+    register_type: Annotated[int, Field(description="寄存器区域:1=I,2=Q,3=M,4=DB,5=V,6=AI。")],
+    point_id: Annotated[str, Field(description="外部点位编码。")]= "",
+    scale_ratio: Annotated[float, Field(description="缩放系数。")]= 1,
+    value_offset: Annotated[float, Field(description="值偏移。")]= 0,
+    group_id: Annotated[int, Field(description="点位分组 id。")]= 0,
+    invalid_values: Annotated[str, Field(description="无效值列表,多个值用逗号分隔。")]= "",
+    valid_range_start: Annotated[float | None, Field(description="合法范围最小值。")]= None,
+    valid_range_end: Annotated[float | None, Field(description="合法范围最大值。")]= None,
+    describe: Annotated[str, Field(description="点位描述。")]= "",
+) -> CollectorPointEditOutput:
     payload: dict[str, Any] = {
-        "ori_id": ori_id,
+        "id": id,
         "device_id": device_id,
         "name": name,
         "address": address,
@@ -271,8 +379,5 @@ def collector_s7_point_edit(
         "valid_range_end": valid_range_end,
         "describe": describe,
     }
-    if register_type:
-        payload["register_type"] = register_type
-    else:
-        payload["register_area"] = register_area
+    payload["register_type"] = register_type
     return api_edit_s7_point(project_key, payload)

+ 9 - 9
tests/test_collector_api.py

@@ -652,7 +652,7 @@ class CollectorApiTests(unittest.TestCase):
         self.assertEqual(request_json.call_count, 2)
         self.assertEqual(request_json.call_args_list[0].args[1], "http://collector.test/api/collector/s7/point/add")
 
-    def test_edit_s7_device_maps_ori_id_and_posts_to_update_endpoint(self) -> None:
+    def test_edit_s7_device_uses_id_and_posts_to_update_endpoint(self) -> None:
         self._patch_project()
         with patch(
             "data_collector_mcp.collector_api.request_json",
@@ -661,7 +661,7 @@ class CollectorApiTests(unittest.TestCase):
             collector_api.edit_s7_device(
                 "dev-01",
                 {
-                    "ori_id": 3,
+                    "id": 3,
                     "name": "s7_edited",
                     "ip": "127.0.0.1",
                     "rock": 0,
@@ -705,7 +705,7 @@ class CollectorApiTests(unittest.TestCase):
         self.assertNotIn("register_area", payload)
         self.assertNotIn("group_id", payload)
 
-    def test_edit_s7_point_maps_ori_id_and_requires_device_id(self) -> None:
+    def test_edit_s7_point_uses_id_and_requires_device_id(self) -> None:
         self._patch_project()
         with patch(
             "data_collector_mcp.collector_api.request_json",
@@ -714,7 +714,7 @@ class CollectorApiTests(unittest.TestCase):
             collector_api.edit_s7_point(
                 "dev-01",
                 {
-                    "ori_id": 101,
+                    "id": 101,
                     "device_id": 3,
                     "name": "m_bool",
                     "register_type": 3,
@@ -735,7 +735,7 @@ class CollectorApiTests(unittest.TestCase):
             collector_api.edit_s7_point(
                 "dev-01",
                 {
-                    "ori_id": 101,
+                    "id": 101,
                     "name": "m_bool",
                     "register_type": 3,
                     "address": "10.2",
@@ -1016,7 +1016,7 @@ class CollectorApiTests(unittest.TestCase):
             collector_api.edit_bacnet_point(
                 "dev-01",
                 {
-                    "ori_id": 101,
+                    "id": 101,
                     "name": "zone_temperature_edited",
                     "object_type": "analogInput",
                     "object_id": 1,
@@ -1026,7 +1026,7 @@ class CollectorApiTests(unittest.TestCase):
         payload = request_json.call_args.kwargs["json_payload"]
         self.assertEqual(payload["object_type"], "AnalogInput")
 
-    def test_edit_bacnet_point_maps_ori_id_to_id(self) -> None:
+    def test_edit_bacnet_point_uses_id(self) -> None:
         self._patch_project()
         response = {"state": 0, "state_info": "成功"}
         with patch(
@@ -1036,7 +1036,7 @@ class CollectorApiTests(unittest.TestCase):
             result = collector_api.edit_bacnet_point(
                 "dev-01",
                 {
-                    "ori_id": 101,
+                    "id": 101,
                     "name": "zone_temperature_edited",
                     "object_type": "AnalogInput",
                     "object_id": 1,
@@ -1070,7 +1070,7 @@ class CollectorApiTests(unittest.TestCase):
         with self.assertRaisesRegex(ValueError, "payload.object_id must be between"):
             collector_api.edit_bacnet_point(
                 "dev-01",
-                {"ori_id": 1, "name": "bad", "object_type": "AnalogInput", "object_id": 4_194_304},
+                {"id": 1, "name": "bad", "object_type": "AnalogInput", "object_id": 4_194_304},
             )
 
 

+ 112 - 29
tests/test_server_tools.py

@@ -50,6 +50,104 @@ class ServerToolTests(unittest.TestCase):
             },
         )
 
+    def test_common_tools_include_field_descriptions_and_output_schema(self) -> None:
+        tools = {tool.name: tool for tool in asyncio.run(app.mcp.list_tools())}
+
+        project_list = tools["project.list"]
+        project_props = project_list.output_schema["properties"]
+        self.assertIn("projects", project_props)
+        project_item_props = project_props["projects"]["items"]["properties"]
+        self.assertEqual(project_item_props["project_key"]["description"], "项目标识,用于其他采集工具的 project_key 参数。")
+
+        device_list = tools["collector.device_list"]
+        self.assertEqual(
+            device_list.parameters["properties"]["project_key"]["description"],
+            "项目标识,来自 project.list 返回的 project_key。",
+        )
+        self.assertEqual(device_list.parameters["properties"]["num_points"]["default"], False)
+        self.assertIn("devices", device_list.output_schema["properties"])
+
+        connect = tools["collector.device_connect"]
+        self.assertEqual(
+            connect.parameters["properties"]["device_type"]["description"],
+            "设备协议类型,可传 modbus、s7、bacnet、ethernet-ip、opc-ua、opc-da、snmp、iec104。",
+        )
+        connect_data_schema = connect.output_schema["properties"]["data"]["anyOf"][0]
+        self.assertIn("running_status", connect_data_schema["properties"])
+
+        disconnect = tools["collector.device_disconnect"]
+        self.assertEqual(disconnect.parameters["properties"]["device_type"]["default"], "modbus")
+        self.assertIn("data", disconnect.output_schema["properties"])
+
+        device_points = tools["collector.device_points"]
+        self.assertEqual(device_points.parameters["properties"]["group_id"]["default"], 0)
+        points_data_schema = device_points.output_schema["properties"]["data"]["anyOf"][0]
+        point_props = points_data_schema["properties"]["point"]["items"]["properties"]
+        self.assertEqual(point_props["present_value"]["description"], "当前内存中的点位最新值。")
+
+    def test_protocol_tools_include_field_descriptions_and_output_schema(self) -> None:
+        tools = {tool.name: tool for tool in asyncio.run(app.mcp.list_tools())}
+
+        point_search = tools["bacnet.point_search"]
+        self.assertEqual(
+            point_search.parameters["properties"]["bacnet_device_id"]["description"],
+            "BACnet 设备对象实例号,范围 0..4194303。",
+        )
+        point_output_props = point_search.output_schema["properties"]
+        self.assertIn("code", point_output_props)
+        point_data_schema = point_output_props["data"]["anyOf"][0]
+        point_props = point_data_schema["properties"]["points"]["items"]["properties"]
+        self.assertEqual(point_props["present_value"]["description"], "当前值;读取 BACnet present-value 得到。")
+
+        device_create = tools["collector.bacnet_device_create"]
+        device_item_props = device_create.parameters["properties"]["devices"]["items"]["properties"]
+        self.assertEqual(device_item_props["ip"]["description"], "BACnet/IP 设备地址。")
+        create_output_props = device_create.output_schema["properties"]
+        self.assertIn("summary", create_output_props)
+        self.assertIn("results", create_output_props)
+
+        device_edit_props = tools["collector.bacnet_device_edit"].parameters["properties"]
+        self.assertIn("id", device_edit_props)
+        self.assertNotIn("ori_id", device_edit_props)
+        point_edit_props = tools["collector.bacnet_point_edit"].parameters["properties"]
+        self.assertIn("id", point_edit_props)
+        self.assertNotIn("ori_id", point_edit_props)
+        self.assertNotIn("data", tools["collector.bacnet_point_edit"].output_schema["properties"])
+
+        modbus_collect = tools["modbus.point_collect_test"]
+        modbus_point_props = modbus_collect.parameters["properties"]["points"]["items"]["properties"]
+        self.assertEqual(modbus_point_props["address"]["description"], "寄存器地址。")
+        self.assertIn("data", modbus_collect.output_schema["properties"])
+        modbus_output_point_props = (
+            modbus_collect.output_schema["properties"]["data"]["anyOf"][0]["properties"]["points"]["items"]["properties"]
+        )
+        self.assertIn("value", modbus_output_point_props)
+        self.assertNotIn("present_value", modbus_output_point_props)
+        for tool_name in ("collector.modbus_device_edit", "collector.modbus_point_edit"):
+            props = tools[tool_name].parameters["properties"]
+            self.assertIn("id", props)
+            self.assertNotIn("ori_id", props)
+        self.assertNotIn("register_type", tools["collector.modbus_point_edit"].parameters["properties"])
+        self.assertIn("1=Read Coils", tools["collector.modbus_point_edit"].parameters["properties"]["func_code"]["description"])
+        self.assertNotIn("data", tools["collector.modbus_point_edit"].output_schema["properties"])
+
+        s7_collect = tools["s7.point_collect_test"]
+        s7_point_props = s7_collect.parameters["properties"]["points"]["items"]["properties"]
+        self.assertEqual(s7_point_props["area"]["description"], "读取区域,可用 DB、M、I、Q、V。")
+        self.assertIn("data", s7_collect.output_schema["properties"])
+        s7_output_point_props = s7_collect.output_schema["properties"]["data"]["anyOf"][0]["properties"]["points"]["items"]["properties"]
+        self.assertIn("value", s7_output_point_props)
+        self.assertNotIn("present_value", s7_output_point_props)
+        for tool_name in ("collector.s7_device_edit", "collector.s7_point_edit"):
+            props = tools[tool_name].parameters["properties"]
+            self.assertIn("id", props)
+            self.assertNotIn("ori_id", props)
+        self.assertNotIn("data", tools["collector.s7_point_edit"].output_schema["properties"])
+        s7_create_point_props = tools["collector.s7_point_create"].parameters["properties"]["points"]["items"]["properties"]
+        self.assertIn("register_type", s7_create_point_props)
+        self.assertNotIn("register_area", s7_create_point_props)
+        self.assertNotIn("register_area", tools["collector.s7_point_edit"].parameters["properties"])
+
     def test_project_list_filters_enabled_projects_and_sorts(self) -> None:
         with patch(
             "data_collector_mcp.common_server.load_projects_config",
@@ -205,7 +303,7 @@ class ServerToolTests(unittest.TestCase):
         with patch("data_collector_mcp.modbus_server.api_edit_modbus_device", return_value={"state": 0}) as api_edit:
             result = modbus_server.collector_modbus_device_edit(
                 project_key="dev-01",
-                ori_id=1,
+                id=1,
                 name="modbus_tcp_edited",
                 device_type=1,
                 ip="127.0.0.1",
@@ -252,7 +350,7 @@ class ServerToolTests(unittest.TestCase):
         with patch("data_collector_mcp.modbus_server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
             result = modbus_server.collector_modbus_point_edit(
                 project_key="dev-01",
-                ori_id=101,
+                id=101,
                 name="holding_register_uint16_edited",
                 address=10,
                 data_type="uint16",
@@ -287,21 +385,6 @@ class ServerToolTests(unittest.TestCase):
             },
         )
 
-    def test_modbus_point_edit_uses_register_type_when_func_code_is_zero(self) -> None:
-        with patch("data_collector_mcp.modbus_server.api_edit_modbus_point", return_value={"state": 0}) as api_edit:
-            modbus_server.collector_modbus_point_edit(
-                project_key="dev-01",
-                ori_id=101,
-                name="holding_register_uint16_edited",
-                address=10,
-                data_type="uint16",
-                register_type="holding_register",
-            )
-
-        payload = api_edit.call_args.args[1]
-        self.assertEqual(payload["register_type"], "holding_register")
-        self.assertNotIn("func_code", payload)
-
     def test_s7_device_create_accepts_batch_devices(self) -> None:
         signature = inspect.signature(s7_server.collector_s7_device_create)
         self.assertIn("devices", signature.parameters)
@@ -320,7 +403,7 @@ class ServerToolTests(unittest.TestCase):
         )
 
     def test_s7_point_create_accepts_batch_points(self) -> None:
-        points = [{"device_id": 3, "name": "db_real", "address": "1.10", "type": "REAL", "register_area": "DB"}]
+        points = [{"device_id": 3, "name": "db_real", "address": "1.10", "type": "REAL", "register_type": 4}]
         with patch("data_collector_mcp.s7_server.api_create_s7_points", return_value={"state": 0}) as api_create:
             result = s7_server.collector_s7_point_create(project_key="dev-01", points=points)
 
@@ -334,7 +417,7 @@ class ServerToolTests(unittest.TestCase):
         with patch("data_collector_mcp.s7_server.api_edit_s7_device", return_value={"state": 0}) as api_edit:
             result = s7_server.collector_s7_device_edit(
                 project_key="dev-01",
-                ori_id=3,
+                id=3,
                 name="s7_edited",
                 ip="127.0.0.1",
                 rock=0,
@@ -346,7 +429,7 @@ class ServerToolTests(unittest.TestCase):
         api_edit.assert_called_once_with(
             "dev-01",
             {
-                "ori_id": 3,
+                "id": 3,
                 "name": "s7_edited",
                 "ip": "127.0.0.1",
                 "rock": 0,
@@ -362,25 +445,25 @@ class ServerToolTests(unittest.TestCase):
             },
         )
 
-    def test_s7_point_edit_uses_register_area_when_register_type_is_zero(self) -> None:
+    def test_s7_point_edit_uses_register_type(self) -> None:
         signature = inspect.signature(s7_server.collector_s7_point_edit)
         self.assertNotIn("payload", signature.parameters)
 
         with patch("data_collector_mcp.s7_server.api_edit_s7_point", return_value={"state": 0}) as api_edit:
             s7_server.collector_s7_point_edit(
                 project_key="dev-01",
-                ori_id=101,
+                id=101,
                 device_id=3,
                 name="db_real",
                 address="1.10",
                 data_type="float32",
-                register_area="DB",
+                register_type=4,
                 point_id="DB_REAL",
             )
 
         payload = api_edit.call_args.args[1]
-        self.assertEqual(payload["register_area"], "DB")
-        self.assertNotIn("register_type", payload)
+        self.assertEqual(payload["register_type"], 4)
+        self.assertNotIn("register_area", payload)
 
     def test_s7_gateway_tools_forward_to_api(self) -> None:
         with patch("data_collector_mcp.s7_server.api_s7_raw_read", return_value={"code": 0}) as raw_read:
@@ -499,7 +582,7 @@ class ServerToolTests(unittest.TestCase):
         ) as api_edit:
             result = bacnet_server.collector_bacnet_device_edit(
                 project_key="dev-01",
-                ori_id=9,
+                id=9,
                 name="bacnet_edited",
                 ip="192.168.1.21",
                 bacnet_device_id=54321,
@@ -554,7 +637,7 @@ class ServerToolTests(unittest.TestCase):
         ) as api_point_edit:
             result = bacnet_server.collector_bacnet_point_edit(
                 project_key="dev-01",
-                ori_id=101,
+                id=101,
                 name="zone_temperature_edited",
                 object_type="AnalogInput",
                 object_id=1,
@@ -564,7 +647,7 @@ class ServerToolTests(unittest.TestCase):
         self.assertEqual(result, {"state": 0})
         payload = api_point_edit.call_args.args[1]
         self.assertEqual(api_point_edit.call_args.args[0], "dev-01")
-        self.assertEqual(payload["ori_id"], 101)
+        self.assertEqual(payload["id"], 101)
         self.assertEqual(payload["name"], "zone_temperature_edited")
         self.assertEqual(payload["object_type"], "AnalogInput")
         self.assertEqual(payload["object_id"], 1)
@@ -576,7 +659,7 @@ class ServerToolTests(unittest.TestCase):
         ) as api_point_edit:
             bacnet_server.collector_bacnet_point_edit(
                 project_key="dev-01",
-                ori_id=101,
+                id=101,
                 name="zone_temperature_edited",
                 object_type="AnalogInput",
                 object_id=1,