浏览代码

新增了请求日志

Lu Xianghui 1 月之前
父节点
当前提交
538bb0d3a0

+ 6 - 1
README.md

@@ -67,6 +67,9 @@ python -m data_collector_mcp
 - `MCP_PORT`,默认 `8501`
 - `MCP_PATH`,默认 `/mcp`
 - `UPSTREAM_REQUEST_TIMEOUT`,默认 `60`
+- `MCP_LOG_ARGUMENT_MAX_LENGTH`,默认 `10000`,MCP tool 入参日志最大长度;超出后截断
+
+服务会为每次 MCP tool 调用输出结构化 JSON 日志,包含 `trace_id`、工具名、入参、开始时间、结束时间和耗时。`password`、`token`、`authorization`、`secret` 等敏感字段会自动脱敏。
 
 ## 测试
 
@@ -144,7 +147,7 @@ $env:DATABASE_URL = "postgresql+psycopg2://postgres:password@127.0.0.1:5432/data
 - `devices[].rock`:机架号
 - `devices[].slot`:槽号
 
-S7 设备可选默认值包括:`port=102`、`device_type=1`、`is_persistent=false`、`device_group_id=0`、`timeout=3`、`alarm_interval=90`、`collect_interval=5`。`tsap_conn_type` 不传时,S7-1200/S7-1500 默认 `PG`,S7-Smart200 默认 `OP`
+S7 设备可选默认值包括:`port=102`、`device_type=1`、`tsap_conn_type=PG`、`is_persistent=false`、`device_group_id=0`、`timeout=3`、`alarm_interval=90`、`collect_interval=5`。`device_type` 使用数字枚举:`1=S7-1200`、`2=S7-1500`、`3=S7-Smart200`;如果来源是网关读取工具的字符串 `S7-Smart200`,创建设备时应转换为 `device_type=3`。`tsap_conn_type` 不传时统一使用 `PG`,只有现场测试或扫描确认需要 `OP`/`BASIC` 时才显式传
 
 批量创建 S7 点位时传 `points` 数组,每个点位必须包含:
 
@@ -154,6 +157,8 @@ S7 设备可选默认值包括:`port=102`、`device_type=1`、`is_persistent=f
 - `points[].data_type` 或 `points[].type`:数据类型
 - `points[].register_type` 或 `points[].register_area`:寄存器区域
 
+S7 点位创建使用汇采格式,不是网关读取格式;不要把网关点位的 `area/start/bit` 直接传给 `collector.s7_point_create`。常见点表类型映射:`BOOL=>bool`、`FLOAT/REAL=>float32`、`SHORT/INT=>int16`、`WORD=>uint16`、`DWORD=>uint32`、`DINT/LONG=>int32`、`DOUBLE/LREAL=>float64`。常见 CSV 区域映射:`I=>I`、`Q/QD=>Q`、`M/MD=>M`、`VD/VW=>V`、`DB/DBD/DBW/DBX=>DB`。BOOL 点把地址列和位地址列合并为 `address="byte.bit"`;DB BOOL 使用 `address="db.byte.bit"`;非 BOOL 使用字节地址,DB 非 BOOL 使用 `address="db.byte"`。
+
 批量创建 BACnet 设备时传 `devices` 数组,每个设备必须包含:
 
 - `devices[].name`:设备名称

+ 0 - 2
data_collector_mcp/collector_api.py

@@ -155,8 +155,6 @@ def _normalize_s7_device_payload(payload: dict[str, Any]) -> dict[str, Any]:
         normalized.pop("group_id", None)
     if "tsap_conn_type" in normalized and normalized["tsap_conn_type"] is not None:
         normalized["tsap_conn_type"] = _normalize_s7_tsap_conn_type(normalized["tsap_conn_type"])
-    elif int(normalized.get("device_type", 1)) == 3:
-        normalized["tsap_conn_type"] = "OP"
     else:
         normalized["tsap_conn_type"] = "PG"
 

+ 0 - 4
data_collector_mcp/gateway_api.py

@@ -131,8 +131,4 @@ def _resolve_s7_tsap_conn_type(device_type: str, tsap_conn_type: str | None) ->
     normalized_tsap = str(tsap_conn_type or "").strip().upper()
     if normalized_tsap:
         return normalized_tsap
-
-    normalized_device_type = str(device_type or "").strip().lower().replace(" ", "")
-    if normalized_device_type in {"s7-smart200", "s7smart200", "smart200"}:
-        return "OP"
     return "PG"

+ 3 - 0
data_collector_mcp/mcp_app.py

@@ -2,6 +2,8 @@ from __future__ import annotations
 
 from fastmcp import FastMCP
 
+from .tool_logging import ToolCallLoggingMiddleware
+
 
 SERVER_INSTRUCTIONS = (
     "Data collector tools. Use project.list first to choose a project_key. "
@@ -14,3 +16,4 @@ SERVER_INSTRUCTIONS = (
 
 
 mcp = FastMCP("data-collector-mcp", instructions=SERVER_INSTRUCTIONS)
+mcp.add_middleware(ToolCallLoggingMiddleware())

+ 20 - 14
data_collector_mcp/s7_server.py

@@ -59,8 +59,8 @@ class S7PointCreateItem(TypedDict):
         "{base_url}/api/dc-gateway/s7/read。请求字段使用网关/汇采一致命名:"
         "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
         "device_type 为设备类型,默认 S7-1200,可传 S7-1200、S7-Smart200。"
-        "tsap_conn_type 可选 PG、OP、BASIC;未传时 S7-1200 使用 PG,"
-        "device_type=S7-Smart200 时使用 OP。read 必须包含 area、start、size;"
+        "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
+        "read 必须包含 area、start、size;"
         "area 可用 DB、M、I、Q,S7-Smart200 还支持 V;area=DB 时 read.db 必须大于 0。"
         "该接口不解析业务值,只返回语义化 communication:S7_CONNECT、S7_CONNECTED、"
         "S7_READ、原始十六进制字节、S7_DISCONNECT 或 S7_ERROR。"
@@ -96,8 +96,8 @@ def s7_raw_read(
         "{base_url}/api/dc-gateway/s7/read_points。请求字段使用网关/汇采一致命名:"
         "ip 为 PLC 地址,port 默认 102,rock 为机架号,slot 为槽号,"
         "device_type 为设备类型,默认 S7-1200,可传 S7-1200、S7-Smart200。"
-        "tsap_conn_type 可选 PG、OP、BASIC;未传时 S7-1200 使用 PG,"
-        "device_type=S7-Smart200 时,tsap_conn_type使用 OP。points 为点位数组,每个点位必须包含 "
+        "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
+        "points 为网关读取格式的点位数组,每个点位必须包含 "
         "area、start、type;area 可用 DB、M、I、Q,S7-Smart200 还支持 V;"
         "area=DB 时 db 必须大于 0。"
         "type 可用 bool、byte、int8、int16、uint16、int32、uint32、int64、uint64、"
@@ -146,8 +146,6 @@ def _resolve_collector_s7_tsap_conn_type(device_type: int, tsap_conn_type: str |
     normalized_tsap = str(tsap_conn_type or "").strip().upper()
     if normalized_tsap:
         return normalized_tsap
-    if int(device_type) == 3:
-        return "OP"
     return "PG"
 
 
@@ -155,15 +153,17 @@ def _resolve_collector_s7_tsap_conn_type(device_type: int, tsap_conn_type: str |
     name="collector.s7_device_create",
     description=(
         "汇采-批量创建 S7 设备。依次调用 {data_collector_base_url}/api/collector/device,"
-        "与 Modbus 创建设备共用地址,但请求体固定 type=s7,入参使用 S7 字段。"
-        "全部设备创建调用完成后会内部调用设备列表并匹配每个设备 id;如果匹配到多个设备,"
-        "选择 id 最大的。devices 每项必须传 name 名称、ip IP 地址、rock 机架号(轨道号)、slot 槽号;"
-        "port 默认 102,device_type 默认 1。tsap_conn_type 可选 PG、OP、BASIC;"
-        "未传时 device_type=1 使用 PG,device_type=3 时使用 OP。"
+        "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;"
+        "未传时统一使用 PG;只有现场测试或扫描确认需要 OP/BASIC 时才显式传。"
         "默认参数: is_persistent=false, "
         "device_group_id=0, timeout=3, alarm_interval=90, collect_interval=5。"
-        "device_type: 1=S7-1200, 2=S7-1500, 3=S7-Smart200。"
-        "tsap_conn_type 可用 PG、OP、BASIC。返回批量结果,state=0 表示全部创建并匹配成功。"
+        "从设备 CSV/网关结果转换时:S7-Smart200=>device_type=3;创建入参示例 "
+        "{name:'plc1',ip:'192.168.1.10',port:102,rock:0,slot:1,device_type:3,tsap_conn_type:'PG'}。"
+        "全部设备创建调用完成后会内部调用设备列表并匹配每个设备 id,"
+        "返回批量结果,state=0 表示全部创建并匹配成功。"
     ),
 )
 def collector_s7_device_create(
@@ -178,7 +178,7 @@ def collector_s7_device_create(
     description=(
         "汇采-编辑 S7 设备。调用 {data_collector_base_url}/api/collector/s7/device/update。"
         "必须传 ori_id 原设备 id、name、ip、rock、slot;port 默认 102,device_type 默认 1,"
-        "tsap_conn_type 可选 PG、OP、BASIC;未传时 device_type=1 使用 PG,device_type=3 时使用 OP。"
+        "tsap_conn_type 可选 PG、OP、BASIC;未传时统一使用 PG;只有明确需要 OP/BASIC 时才显式传。"
         "编辑前设备不能处于已连接状态;若已连接,请先调用 "
         "collector.device_disconnect,并传 device_type=s7。该接口是全量更新语义。"
         "默认参数: is_persistent=false, device_group_id=0, timeout=3, alarm_interval=90, "
@@ -232,8 +232,14 @@ def collector_s7_device_edit(
         "valid_range_end=null。register_type: 1=I输入区, 2=Q输出区, 3=M存储区, 4=DB数据块, "
         "5=V区, 6=AI模拟输入;register_area 可用 I、Q、M、DB、V、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 地址格式: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。"
+        "注意本工具是汇采创建格式,不是网关读取格式;不要传 area/start/bit。"
+        "从常见 CSV 区域转换时:I=>register_area I;Q/QD=>Q;M/MD=>M;VD/VW=>V;"
+        "DB/DBD/DBW/DBX=>DB。BOOL 点把地址列和位地址列合并为 address='byte.bit';"
+        "DB BOOL 合并为 address='db.byte.bit';非 BOOL 使用字节地址,DB 非 BOOL 使用 'db.byte'。"
         "响应透传上游 JSON,state=0 表示业务成功。"
     ),
 )

+ 114 - 0
data_collector_mcp/tool_logging.py

@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
+
+
+SENSITIVE_KEYS = {
+    "access_token",
+    "auth_token",
+    "authorization",
+    "password",
+    "refresh_token",
+    "secret",
+    "token",
+}
+
+
+def _utc_now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _argument_max_length() -> int:
+    try:
+        return max(0, int(str(os.getenv("MCP_LOG_ARGUMENT_MAX_LENGTH", "10000")).strip()))
+    except (TypeError, ValueError):
+        return 10000
+
+
+def _redact(value: Any) -> Any:
+    if isinstance(value, dict):
+        return {
+            key: "***REDACTED***" if str(key).lower() in SENSITIVE_KEYS else _redact(item)
+            for key, item in value.items()
+        }
+    if isinstance(value, list):
+        return [_redact(item) for item in value]
+    if isinstance(value, tuple):
+        return [_redact(item) for item in value]
+    return value
+
+
+def _serialize_arguments(arguments: Any) -> tuple[Any, bool]:
+    redacted = _redact(arguments or {})
+    max_length = _argument_max_length()
+    text = json.dumps(redacted, ensure_ascii=False, default=str, separators=(",", ":"))
+    if max_length and len(text) > max_length:
+        return text[:max_length] + "...", True
+    return redacted, False
+
+
+class ToolCallLoggingMiddleware(Middleware):
+    def __init__(self, logger: logging.Logger | None = None) -> None:
+        self.logger = logger or logging.getLogger("data_collector_mcp.tool_calls")
+
+    def _log(self, payload: dict[str, Any], level: int = logging.INFO) -> None:
+        self.logger.log(level, json.dumps(payload, ensure_ascii=False, default=str, separators=(",", ":")))
+
+    async def on_call_tool(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+        trace_id = uuid.uuid4().hex
+        tool_name = str(getattr(context.message, "name", "unknown") or "unknown")
+        arguments, arguments_truncated = _serialize_arguments(getattr(context.message, "arguments", None))
+        started_at = _utc_now_iso()
+        start_perf = time.perf_counter()
+
+        start_payload: dict[str, Any] = {
+            "event": "mcp_tool_start",
+            "trace_id": trace_id,
+            "tool_name": tool_name,
+            "arguments": arguments,
+            "arguments_truncated": arguments_truncated,
+            "started_at": started_at,
+        }
+        self._log(start_payload)
+
+        try:
+            result = await call_next(context)
+        except Exception as exc:
+            ended_at = _utc_now_iso()
+            self._log(
+                {
+                    "event": "mcp_tool_error",
+                    "trace_id": trace_id,
+                    "tool_name": tool_name,
+                    "status": "error",
+                    "started_at": started_at,
+                    "ended_at": ended_at,
+                    "duration_ms": round((time.perf_counter() - start_perf) * 1000, 2),
+                    "error_type": type(exc).__name__,
+                    "error": str(exc),
+                },
+                logging.ERROR,
+            )
+            raise
+
+        ended_at = _utc_now_iso()
+        self._log(
+            {
+                "event": "mcp_tool_end",
+                "trace_id": trace_id,
+                "tool_name": tool_name,
+                "status": "success",
+                "started_at": started_at,
+                "ended_at": ended_at,
+                "duration_ms": round((time.perf_counter() - start_perf) * 1000, 2),
+            }
+        )
+        return result

+ 17 - 5
docs/接口汇总.md

@@ -583,9 +583,9 @@ S7 网关接口通过 HTTP 连接 Siemens S7 TCP 设备,支持连接组合扫
 | `port` | integer | 否 | `102` | `1..65535` | S7 TCP 端口,通常固定为 `102`。 |
 | `rock` | integer | 是 | 无 | `0..31` | PLC 机架号。字段名沿用汇采 S7 设备接口。 |
 | `slot` | integer | 是 | 无 | `0..31` | PLC 槽号。 |
-| `tsap_conn_type` | string | 否 | `S7-1200` 使用 `PG`;`S7-Smart200` 使用 `OP` | `PG`、`OP`、`BASIC` | Snap7 连接类型。大小写不敏感,服务内部统一转为大写。 |
+| `tsap_conn_type` | string | 否 | `PG` | `PG`、`OP`、`BASIC` | Snap7 连接类型。大小写不敏感,服务内部统一转为大写。 |
 
-约定:当 `device_type=S7-Smart200` 时,`tsap_conn_type` 使用 `OP`。如果请求中显式传入 `tsap_conn_type`,以请求值为准;未传时按 `device_type` 选择默认值
+约定:未传 `tsap_conn_type` 时统一使用 `PG`。如果请求中显式传入 `tsap_conn_type`,以请求值为准;只有现场测试或扫描确认需要 `OP`/`BASIC` 时才显式传
 
 连接类型映射:
 
@@ -1221,7 +1221,7 @@ AI 构造 `/modbus/read_points` 点位时应按以下步骤:
 
 AI 构造 `/s7/read_points` 点位时应按以下步骤:
 
-1. 先确定 `device_type`,默认使用 `S7-1200`;如果是 `S7-Smart200`,传 `device_type=S7-Smart200` 且 `tsap_conn_type=OP`。
+1. 先确定 `device_type`,默认使用 `S7-1200`;如果是 `S7-Smart200`,传 `device_type=S7-Smart200`,`tsap_conn_type` 未传时默认 `PG`。
 2. 如果不确定 S7 连接参数,先调用 `/s7/connect_scan` 获取可用的 `rock`、`slot`、`tsap_conn_type`。
 3. 根据设备点表选择 `area`,DB 区传 `area=DB` 且填写大于 `0` 的 `db`;`S7-Smart200` 的 V 区传 `area=V`。
 4. 将 S7 地址换算为字节偏移,填入 `start`。
@@ -1923,6 +1923,18 @@ S7 点位 `address` 是字符串,不是纯数字。采集时会按 `register_t
 
 `bool` 点位位下标范围为 `0..7`。DB 区必须包含 DB 号;例如 DB1 的第 10 字节第 2 位写为 `1.10.2`。
 
+常见 S7 CSV/点表字段转换到 MCP 创建入参时,使用汇采创建格式,不要直接使用网关读取格式 `area/start/bit`:
+
+| CSV 区域 | `register_area` | 地址转换 |
+|---|---|---|
+| `I` | `I` | BOOL 合并地址列和位地址列为 `byte.bit`;非 BOOL 使用字节地址。 |
+| `Q`、`QD` | `Q` | BOOL 使用 `byte.bit`;非 BOOL 使用字节地址。 |
+| `M`、`MD` | `M` | BOOL 使用 `byte.bit`;非 BOOL 使用字节地址。 |
+| `VD`、`VW` | `V` | BOOL 使用 `byte.bit`;非 BOOL 使用字节地址。 |
+| `DB`、`DBD`、`DBW`、`DBX` | `DB` | BOOL 使用 `db.byte.bit`;非 BOOL 使用 `db.byte`。 |
+
+常见点表类型映射:`BOOL=>bool`、`FLOAT/REAL=>float32`、`SHORT/INT=>int16`、`WORD=>uint16`、`DWORD=>uint32`、`DINT/LONG=>int32`、`DOUBLE/LREAL=>float64`。
+
 ### 1. 创建 S7 设备
 
 #### 基本信息
@@ -1950,7 +1962,7 @@ S7 点位 `address` 是字符串,不是纯数字。采集时会按 `register_t
 | `device_group_id` | int | 否 | Go 零值 `0` | 父设备分组 ID。`0` 表示顶层设备。 |
 | `timeout` | int | 否 | 创建接口请求结构包含该字段,但当前创建逻辑未写入内部配置 | 超时时间,单位秒。 |
 
-MCP 工具 `collector.s7_device_create` 接收 `devices` 数组并逐个调用本接口。MCP 默认值包括 `port=102`、`device_type=1`、`tsap_conn_type=null`、`is_persistent=false`、`device_group_id=0`、`timeout=3`、`alarm_interval=90`、`collect_interval=5`;`tsap_conn_type` 不传或为 `null` 时,S7-1200/S7-1500 默认 `PG`,S7-Smart200 默认 `OP`。全部创建设备调用完成后,MCP 会调用设备列表接口匹配 `device_id`,匹配到多个时选择最大 `id`。
+MCP 工具 `collector.s7_device_create` 接收 `devices` 数组并逐个调用本接口。MCP 默认值包括 `port=102`、`device_type=1`、`tsap_conn_type=PG`、`is_persistent=false`、`device_group_id=0`、`timeout=3`、`alarm_interval=90`、`collect_interval=5`;`tsap_conn_type` 不传或为 `null` 时统一使用 `PG`。只有现场测试或扫描确认需要 `OP`/`BASIC` 时才显式传。全部创建设备调用完成后,MCP 会调用设备列表接口匹配 `device_id`,匹配到多个时选择最大 `id`。
 
 #### 请求示例
 
@@ -2091,7 +2103,7 @@ MCP 工具 `collector.s7_device_create` 接收 `devices` 数组并逐个调用
 | `valid_range_start` | number/null | 否 | `null` | 合法范围最小值。 |
 | `valid_range_end` | number/null | 否 | `null` | 合法范围最大值。 |
 
-MCP 工具 `collector.s7_point_create` 接收 `points` 数组并逐个调用本接口。每个点位必须自带 `device_id`;MCP 默认值包括 `point_id=""`、`scale_ratio=1`、`value_offset=0`、`group_Id=0`、`invalid_values=""`、`valid_range_start=null`、`valid_range_end=null`。可传 `register_area`,MCP 会转换为本接口的 `register_type`。
+MCP 工具 `collector.s7_point_create` 接收 `points` 数组并逐个调用本接口。每个点位必须自带 `device_id`;MCP 默认值包括 `point_id=""`、`scale_ratio=1`、`value_offset=0`、`group_Id=0`、`invalid_values=""`、`valid_range_start=null`、`valid_range_end=null`。调用 MCP 时可传 `group_id`,MCP 会转换为接口字段 `group_Id`;可传 `register_area`,MCP 会转换为本接口的 `register_type`。注意本工具使用汇采创建格式,不接受网关读取格式 `area/start/bit`。
 
 #### 请求示例:DB 区 float32
 

+ 21 - 0
tests/test_collector_api.py

@@ -562,6 +562,27 @@ class CollectorApiTests(unittest.TestCase):
         self.assertEqual(payload["slot"], 1)
         self.assertNotIn("group_id", payload)
 
+    def test_create_s7_device_defaults_smart200_tsap_to_pg(self) -> None:
+        self._patch_project()
+        with patch(
+            "data_collector_mcp.collector_api.request_json",
+            return_value={"state": 0},
+        ) as request_json:
+            collector_api.create_s7_device(
+                "dev-01",
+                {
+                    "name": "smart200_1",
+                    "ip": "127.0.0.1",
+                    "rock": 0,
+                    "slot": 1,
+                    "device_type": 3,
+                },
+            )
+
+        payload = request_json.call_args.kwargs["json_payload"]
+        self.assertEqual(payload["device_type"], 3)
+        self.assertEqual(payload["tsap_conn_type"], "PG")
+
     def test_create_s7_devices_batches_then_matches_largest_id(self) -> None:
         self._patch_project()
         devices = [

+ 2 - 2
tests/test_gateway_api.py

@@ -182,7 +182,7 @@ class GatewayApiTests(unittest.TestCase):
             },
         )
 
-    def test_s7_point_collect_test_defaults_smart200_tsap_to_op(self) -> None:
+    def test_s7_point_collect_test_defaults_smart200_tsap_to_pg(self) -> None:
         with patch(
             "data_collector_mcp.gateway_api.find_project_config",
             return_value={"project_key": "dev-01", "base_url": "http://gateway.test"},
@@ -201,7 +201,7 @@ class GatewayApiTests(unittest.TestCase):
 
         payload = request_json.call_args.kwargs["json_payload"]
         self.assertEqual(payload["device_type"], "S7-Smart200")
-        self.assertEqual(payload["tsap_conn_type"], "OP")
+        self.assertEqual(payload["tsap_conn_type"], "PG")
 
     def test_s7_connect_scan_posts_ip_only(self) -> None:
         response = {"code": 0, "msg": "success", "data": {"available": []}}

+ 120 - 0
tests/test_tool_logging.py

@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import unittest
+from unittest.mock import patch
+
+from fastmcp.server.middleware import MiddlewareContext
+from mcp.types import CallToolRequestParams
+
+from data_collector_mcp.tool_logging import ToolCallLoggingMiddleware
+
+
+class ToolCallLoggingMiddlewareTests(unittest.TestCase):
+    def test_success_logs_start_and_end_with_same_trace_id(self) -> None:
+        middleware = ToolCallLoggingMiddleware()
+        context = MiddlewareContext(
+            message=CallToolRequestParams(
+                name="collector.device_list",
+                arguments={"project_key": "dev-01", "num_points": False},
+            ),
+            method="tools/call",
+        )
+
+        async def call_next(received_context: MiddlewareContext) -> dict[str, int]:
+            self.assertIs(received_context, context)
+            return {"state": 0}
+
+        with self.assertLogs("data_collector_mcp.tool_calls", level="INFO") as logs:
+            result = asyncio.run(middleware.on_call_tool(context, call_next))
+
+        self.assertEqual(result, {"state": 0})
+        payloads = [json.loads(line.split("INFO:data_collector_mcp.tool_calls:", 1)[1]) for line in logs.output]
+        self.assertEqual([item["event"] for item in payloads], ["mcp_tool_start", "mcp_tool_end"])
+        self.assertEqual(payloads[0]["trace_id"], payloads[1]["trace_id"])
+        self.assertEqual(payloads[0]["tool_name"], "collector.device_list")
+        self.assertEqual(payloads[0]["arguments"], {"project_key": "dev-01", "num_points": False})
+        self.assertFalse(payloads[0]["arguments_truncated"])
+        self.assertEqual(payloads[1]["status"], "success")
+        self.assertIn("duration_ms", payloads[1])
+
+    def test_each_call_gets_unique_trace_id(self) -> None:
+        middleware = ToolCallLoggingMiddleware()
+        context = MiddlewareContext(
+            message=CallToolRequestParams(name="project.list", arguments={}),
+            method="tools/call",
+        )
+
+        async def call_next(received_context: MiddlewareContext) -> dict[str, int]:
+            return {"total": 0}
+
+        with self.assertLogs("data_collector_mcp.tool_calls", level="INFO") as logs:
+            asyncio.run(middleware.on_call_tool(context, call_next))
+            asyncio.run(middleware.on_call_tool(context, call_next))
+
+        starts = [
+            json.loads(line.split("INFO:data_collector_mcp.tool_calls:", 1)[1])
+            for line in logs.output
+            if "mcp_tool_start" in line
+        ]
+        self.assertEqual(len(starts), 2)
+        self.assertNotEqual(starts[0]["trace_id"], starts[1]["trace_id"])
+
+    def test_error_logs_start_and_error_with_same_trace_id(self) -> None:
+        middleware = ToolCallLoggingMiddleware()
+        context = MiddlewareContext(
+            message=CallToolRequestParams(name="collector.device_list", arguments={"project_key": "missing"}),
+            method="tools/call",
+        )
+
+        async def call_next(received_context: MiddlewareContext) -> dict[str, int]:
+            raise ValueError("project_key not found: missing")
+
+        with self.assertLogs("data_collector_mcp.tool_calls", level="INFO") as logs:
+            with self.assertRaisesRegex(ValueError, "project_key not found"):
+                asyncio.run(middleware.on_call_tool(context, call_next))
+
+        start_payload = json.loads(logs.output[0].split("INFO:data_collector_mcp.tool_calls:", 1)[1])
+        error_payload = json.loads(logs.output[1].split("ERROR:data_collector_mcp.tool_calls:", 1)[1])
+        self.assertEqual(start_payload["event"], "mcp_tool_start")
+        self.assertEqual(error_payload["event"], "mcp_tool_error")
+        self.assertEqual(start_payload["trace_id"], error_payload["trace_id"])
+        self.assertEqual(error_payload["status"], "error")
+        self.assertEqual(error_payload["error_type"], "ValueError")
+        self.assertEqual(error_payload["error"], "project_key not found: missing")
+
+    def test_arguments_are_redacted_and_truncated(self) -> None:
+        middleware = ToolCallLoggingMiddleware()
+        context = MiddlewareContext(
+            message=CallToolRequestParams(
+                name="example.secret_tool",
+                arguments={
+                    "project_key": "dev-01",
+                    "password": "plain-text",
+                    "nested": {"token": "abc123"},
+                    "large": "x" * 100,
+                },
+            ),
+            method="tools/call",
+        )
+
+        async def call_next(received_context: MiddlewareContext) -> dict[str, int]:
+            return {"state": 0}
+
+        with patch.dict("os.environ", {"MCP_LOG_ARGUMENT_MAX_LENGTH": "60"}):
+            with self.assertLogs("data_collector_mcp.tool_calls", level="INFO") as logs:
+                asyncio.run(middleware.on_call_tool(context, call_next))
+
+        start_payload = json.loads(logs.output[0].split("INFO:data_collector_mcp.tool_calls:", 1)[1])
+        self.assertTrue(start_payload["arguments_truncated"])
+        self.assertIsInstance(start_payload["arguments"], str)
+        self.assertIn("***REDACTED***", start_payload["arguments"])
+        self.assertNotIn("plain-text", start_payload["arguments"])
+        self.assertNotIn("abc123", start_payload["arguments"])
+
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.INFO)
+    unittest.main()