mcpsignals 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ .venv/
8
+ .env
9
+ .claude/worktrees
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcpsignals contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcpsignals
3
+ Version: 1.0.1
4
+ Summary: Drop-in instrumentation for MCP servers. Writes tool-call events to a database you own.
5
+ Project-URL: Homepage, https://github.com/zentered-studios/mcpsignals#readme
6
+ Project-URL: Repository, https://github.com/zentered-studios/mcpsignals
7
+ Project-URL: Issues, https://github.com/zentered-studios/mcpsignals/issues
8
+ Author: mcpsignals contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: mcp>=2.0.0
19
+ Provides-Extra: bigquery
20
+ Requires-Dist: google-cloud-bigquery>=3.11; extra == 'bigquery'
21
+ Provides-Extra: otlp
22
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otlp'
23
+ Provides-Extra: postgres
24
+ Requires-Dist: asyncpg>=0.29; extra == 'postgres'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # mcpsignals (Python)
28
+
29
+ Drop-in instrumentation for MCP servers. Wrap your server, point it at a
30
+ sink, and it records what agents did with your tools into a database you
31
+ own - no hosted service, no account.
32
+
33
+ Requires **Python 3.10 or newer**. Targets the v2 `mcp` SDK (`MCPServer` /
34
+ low-level `Server`, `mcp>=2.0.0`).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install mcpsignals
40
+ # with a warehouse sink:
41
+ pip install "mcpsignals[postgres]" # or [bigquery], [otlp]
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from mcp.server.mcpserver import MCPServer
48
+ from mcpsignals import instrument
49
+
50
+ server = MCPServer("my-server")
51
+ instrument(server, server_name="my-server", server_version="1.0.0")
52
+
53
+
54
+ @server.tool()
55
+ def search(query: str) -> str:
56
+ """Search something."""
57
+ return f"results for {query}"
58
+ ```
59
+
60
+ Call `instrument()` any time before the server starts handling requests. It
61
+ appends to `server.middleware`, and that chain is rebuilt from the live list
62
+ on every request, so calling it before or after your `@server.tool()`
63
+ definitions makes no difference. The Node.js package differs here: it wraps
64
+ `registerTool`, so it has to run before any tool is registered.
65
+
66
+ With no sink configured it writes JSON lines to stdout. To write to your
67
+ own warehouse instead:
68
+
69
+ ```python
70
+ from mcpsignals import instrument
71
+ from mcpsignals.sinks import PostgresSink
72
+
73
+ instrument(server, server_name="my-server", sinks=[PostgresSink(dsn="postgresql://...")])
74
+ ```
75
+
76
+ ## Argument capture and redaction
77
+
78
+ Off by default: no tool arguments are recorded unless you opt in with
79
+ `capture_arguments=True`. Even then, by default only argument **keys and
80
+ value types** are recorded, never values. See the root README's redaction
81
+ section before enabling this in anything handling real user data.
82
+
83
+ ## Known limitation: `session_id`
84
+
85
+ `mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
86
+ expose the transport's connection-level session id (only the
87
+ handler-facing `Context` class does, via a private accessor middleware
88
+ doesn't have). `session_id` on emitted events is therefore only ever
89
+ populated when intent capture is enabled and the calling agent supplies
90
+ one - not from the underlying transport connection. See `instrument.py`
91
+ for details; this will be revisited if/when upstream exposes it to
92
+ middleware.
93
+
94
+ ## Full docs
95
+
96
+ See the [root README](../../README.md) for the redaction model, the sink
97
+ comparison, and intent capture, and [`schema/events.md`](../../schema/events.md)
98
+ for the event field reference.
@@ -0,0 +1,72 @@
1
+ # mcpsignals (Python)
2
+
3
+ Drop-in instrumentation for MCP servers. Wrap your server, point it at a
4
+ sink, and it records what agents did with your tools into a database you
5
+ own - no hosted service, no account.
6
+
7
+ Requires **Python 3.10 or newer**. Targets the v2 `mcp` SDK (`MCPServer` /
8
+ low-level `Server`, `mcp>=2.0.0`).
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install mcpsignals
14
+ # with a warehouse sink:
15
+ pip install "mcpsignals[postgres]" # or [bigquery], [otlp]
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ from mcp.server.mcpserver import MCPServer
22
+ from mcpsignals import instrument
23
+
24
+ server = MCPServer("my-server")
25
+ instrument(server, server_name="my-server", server_version="1.0.0")
26
+
27
+
28
+ @server.tool()
29
+ def search(query: str) -> str:
30
+ """Search something."""
31
+ return f"results for {query}"
32
+ ```
33
+
34
+ Call `instrument()` any time before the server starts handling requests. It
35
+ appends to `server.middleware`, and that chain is rebuilt from the live list
36
+ on every request, so calling it before or after your `@server.tool()`
37
+ definitions makes no difference. The Node.js package differs here: it wraps
38
+ `registerTool`, so it has to run before any tool is registered.
39
+
40
+ With no sink configured it writes JSON lines to stdout. To write to your
41
+ own warehouse instead:
42
+
43
+ ```python
44
+ from mcpsignals import instrument
45
+ from mcpsignals.sinks import PostgresSink
46
+
47
+ instrument(server, server_name="my-server", sinks=[PostgresSink(dsn="postgresql://...")])
48
+ ```
49
+
50
+ ## Argument capture and redaction
51
+
52
+ Off by default: no tool arguments are recorded unless you opt in with
53
+ `capture_arguments=True`. Even then, by default only argument **keys and
54
+ value types** are recorded, never values. See the root README's redaction
55
+ section before enabling this in anything handling real user data.
56
+
57
+ ## Known limitation: `session_id`
58
+
59
+ `mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
60
+ expose the transport's connection-level session id (only the
61
+ handler-facing `Context` class does, via a private accessor middleware
62
+ doesn't have). `session_id` on emitted events is therefore only ever
63
+ populated when intent capture is enabled and the calling agent supplies
64
+ one - not from the underlying transport connection. See `instrument.py`
65
+ for details; this will be revisited if/when upstream exposes it to
66
+ middleware.
67
+
68
+ ## Full docs
69
+
70
+ See the [root README](../../README.md) for the redaction model, the sink
71
+ comparison, and intent capture, and [`schema/events.md`](../../schema/events.md)
72
+ for the event field reference.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mcpsignals"
7
+ # Placeholder, and deliberately not kept in sync by hand. semantic-release
8
+ # stamps the real version in at release time and never commits it back, exactly
9
+ # as packages/node/package.json is left at 0.1.0. Do not read this as the
10
+ # published version - check PyPI or the git tags for that.
11
+ version = "1.0.1"
12
+ description = "Drop-in instrumentation for MCP servers. Writes tool-call events to a database you own."
13
+ readme = "README.md"
14
+ license = "MIT"
15
+ requires-python = ">=3.10"
16
+ authors = [{ name = "mcpsignals contributors" }]
17
+ classifiers = [
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = ["mcp>=2.0.0"]
26
+
27
+ [project.optional-dependencies]
28
+ postgres = ["asyncpg>=0.29"]
29
+ bigquery = ["google-cloud-bigquery>=3.11"]
30
+ otlp = ["opentelemetry-api>=1.20"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/zentered-studios/mcpsignals#readme"
34
+ Repository = "https://github.com/zentered-studios/mcpsignals"
35
+ Issues = "https://github.com/zentered-studios/mcpsignals/issues"
36
+
37
+ # Not an optional-dependencies extra: dev tooling should not be installable by
38
+ # consumers as `pip install mcpsignals[dev]`. PEP 735 groups stay out of the
39
+ # published metadata entirely.
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=8.3",
43
+ "pytest-asyncio>=0.24", # required by asyncio_mode = "auto" below
44
+ "ruff>=0.15,<0.16", # single source of truth for the version CI uses
45
+ ]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/mcpsignals"]
49
+
50
+ [tool.pytest.ini_options]
51
+ asyncio_mode = "auto"
52
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
2
+ from mcpsignals.instrument import instrument
3
+ from mcpsignals.redaction import RedactionConfig
4
+
5
+ __all__ = [
6
+ "instrument",
7
+ "RedactionConfig",
8
+ "ToolCallEvent",
9
+ "SessionSummaryEvent",
10
+ ]
@@ -0,0 +1,98 @@
1
+ """Batches events and flushes to every configured sink on a size threshold
2
+ or an interval, whichever comes first. A sink failure is caught, logged at
3
+ most once per sink instance, and never propagates - a broken sink must
4
+ never break the host MCP server.
5
+ """
6
+
7
+ import asyncio
8
+ import atexit
9
+ import logging
10
+ from collections.abc import Sequence
11
+
12
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
13
+ from mcpsignals.sinks.base import Sink
14
+
15
+ logger = logging.getLogger("mcpsignals")
16
+
17
+ Event = ToolCallEvent | SessionSummaryEvent
18
+
19
+
20
+ class EventBuffer:
21
+ def __init__(
22
+ self,
23
+ sinks: Sequence[Sink],
24
+ buffer_size: int = 20,
25
+ flush_interval_s: float = 5.0,
26
+ ):
27
+ self._sinks = list(sinks)
28
+ self._buffer_size = buffer_size
29
+ self._flush_interval_s = flush_interval_s
30
+ self._events: list[Event] = []
31
+ self._lock = asyncio.Lock()
32
+ self._warned_sinks: set[int] = set()
33
+ self._interval_task: asyncio.Task | None = None
34
+ atexit.register(self._atexit_flush)
35
+
36
+ def _ensure_interval_task(self) -> None:
37
+ # Started lazily on first event, from inside a running event loop -
38
+ # constructing EventBuffer itself must not require a loop to exist yet.
39
+ if self._interval_task is not None:
40
+ return
41
+ try:
42
+ loop = asyncio.get_running_loop()
43
+ except RuntimeError:
44
+ return
45
+ self._interval_task = loop.create_task(self._interval_loop())
46
+
47
+ async def _interval_loop(self) -> None:
48
+ while True:
49
+ await asyncio.sleep(self._flush_interval_s)
50
+ await self.flush()
51
+
52
+ async def add(self, event: Event) -> None:
53
+ self._ensure_interval_task()
54
+ async with self._lock:
55
+ self._events.append(event)
56
+ should_flush = len(self._events) >= self._buffer_size
57
+ if should_flush:
58
+ await self.flush()
59
+
60
+ async def flush(self) -> None:
61
+ async with self._lock:
62
+ if not self._events:
63
+ return
64
+ batch, self._events = self._events, []
65
+
66
+ results = await asyncio.gather(
67
+ *(self._write_to_sink(sink, batch) for sink in self._sinks),
68
+ return_exceptions=True,
69
+ )
70
+ for result in results:
71
+ if isinstance(result, Exception):
72
+ # _write_to_sink already logged; nothing else to do here.
73
+ pass
74
+
75
+ async def _write_to_sink(self, sink: Sink, batch: list[Event]) -> None:
76
+ try:
77
+ await sink.write(batch)
78
+ except Exception as exc: # noqa: BLE001 - a sink must never break the host server
79
+ sink_id = id(sink)
80
+ if sink_id not in self._warned_sinks:
81
+ self._warned_sinks.add(sink_id)
82
+ logger.error("mcpsignals: sink %r failed, dropping batch: %s", sink, exc)
83
+
84
+ def _atexit_flush(self) -> None:
85
+ if not self._events:
86
+ return
87
+ # Best-effort only: there is no guarantee a loop is available to
88
+ # await a real flush at interpreter shutdown. Callers that need a
89
+ # guaranteed flush should call `await buffer.flush()` explicitly
90
+ # before shutting down.
91
+ try:
92
+ asyncio.run(self.flush())
93
+ except RuntimeError:
94
+ logger.warning(
95
+ "mcpsignals: %d buffered event(s) dropped at exit (no event loop available "
96
+ "to flush) - call `await flush()` explicitly before shutdown for a guarantee",
97
+ len(self._events),
98
+ )
@@ -0,0 +1,26 @@
1
+ """Best-effort classification of a free-text error message into a coarse
2
+ bucket. This is NOT ground truth - `success` is. See schema/events.md for
3
+ the documented false-positive mode.
4
+ """
5
+
6
+ import re
7
+
8
+ _NOT_FOUND = re.compile(r"not found|does not exist|no such", re.IGNORECASE)
9
+ _EMPTY = re.compile(r"\bempty\b|no results?|nothing found|zero results", re.IGNORECASE)
10
+ _VALIDATION = re.compile(r"invalid|required|expected|must be|validation|schema", re.IGNORECASE)
11
+
12
+
13
+ def classify_error(message: str | None) -> str | None:
14
+ """Bucket an error message into not_found / empty / validation / internal.
15
+
16
+ Returns None when there is no message to classify (i.e. the call succeeded).
17
+ """
18
+ if not message:
19
+ return None
20
+ if _NOT_FOUND.search(message):
21
+ return "not_found"
22
+ if _EMPTY.search(message):
23
+ return "empty"
24
+ if _VALIDATION.search(message):
25
+ return "validation"
26
+ return "internal"
@@ -0,0 +1,44 @@
1
+ """Event dataclasses. Field names match schema/events.md exactly."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Literal
6
+
7
+
8
+ @dataclass
9
+ class ToolCallEvent:
10
+ event_type: Literal["tool_call"] = field(init=False, default="tool_call")
11
+ ts: datetime = field(default=None) # type: ignore[assignment]
12
+ server_name: str = ""
13
+ server_version: str | None = None
14
+ tool_name: str = ""
15
+ session_id: str | None = None
16
+ agent_id: str | None = None
17
+ client_name: str | None = None
18
+ client_version: str | None = None
19
+ user_id: str | None = None
20
+ org_id: str | None = None
21
+ duration_ms: int = 0
22
+ success: bool = True
23
+ error_kind: str | None = None
24
+ error_message: str | None = None
25
+ request_bytes: int = 0
26
+ response_bytes: int = 0
27
+ arguments: dict[str, Any] | None = None
28
+ intent: str | None = None
29
+ transport: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class SessionSummaryEvent:
34
+ event_type: Literal["session_summary"] = field(init=False, default="session_summary")
35
+ ts: datetime = field(default=None) # type: ignore[assignment]
36
+ session_id: str = ""
37
+ server_name: str = ""
38
+ server_version: str | None = None
39
+ user_id: str | None = None
40
+ org_id: str | None = None
41
+ call_count: int = 0
42
+ distinct_tools_used: int = 0
43
+ wall_duration_ms: int = 0
44
+ error_count: int = 0
@@ -0,0 +1,212 @@
1
+ """Core instrumentation entry point.
2
+
3
+ Built on the `mcp` v2 SDK's `middleware` list (`server.middleware.append(fn)`),
4
+ present on both `MCPServer` and the low-level `Server` - the same mechanism
5
+ works for both, no separate code paths needed. See
6
+ https://py.sdk.modelcontextprotocol.io/v2/advanced/middleware/.
7
+
8
+ Known SDK limitation (verified against the installed mcp==2.0.0 source,
9
+ mcp/server/context.py): `ServerRequestContext` - what middleware receives -
10
+ does not publicly expose the transport's connection-level session id or a
11
+ `connection` accessor (only the handler-facing `Context` class does, via a
12
+ private `Connection` it doesn't share with middleware). We do not reach into
13
+ that private attribute. As a result `session_id` on emitted events is only
14
+ ever populated from the optional intent-capture value the calling agent
15
+ supplies - it is `None` for calls where intent capture is off or the caller
16
+ didn't pass one, even though the connection may well have a real session id.
17
+ Track https://github.com/modelcontextprotocol/python-sdk for this being
18
+ exposed to middleware in a future release.
19
+ """
20
+
21
+ import inspect
22
+ import json
23
+ import time
24
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
25
+ from dataclasses import replace
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+ from mcpsignals.buffer import EventBuffer
30
+ from mcpsignals.error_kind import classify_error
31
+ from mcpsignals.events import ToolCallEvent
32
+ from mcpsignals.intent_capture import enabled_for, inject_schema, strip_injected
33
+ from mcpsignals.redaction import RedactionConfig, redact_arguments
34
+ from mcpsignals.sinks.base import Sink
35
+ from mcpsignals.sinks.console import ConsoleSink
36
+
37
+ ResolveIdentity = Callable[
38
+ [Any], tuple[str | None, str | None] | Awaitable[tuple[str | None, str | None]]
39
+ ]
40
+
41
+
42
+ def _is_error_result(result: Any) -> bool:
43
+ # `call_next` for `tools/call` returns a plain dict (wire-shaped, camelCase
44
+ # keys) in mcp==2.0.0, not a CallToolResult instance - verified against the
45
+ # installed package. Handle both shapes so this survives either.
46
+ if isinstance(result, Mapping):
47
+ return bool(result.get("isError", result.get("is_error", False)))
48
+ return bool(getattr(result, "is_error", False))
49
+
50
+
51
+ def _result_content(result: Any) -> Any:
52
+ if isinstance(result, Mapping):
53
+ return result.get("content")
54
+ return getattr(result, "content", None)
55
+
56
+
57
+ def _content_to_text(content: Any) -> str | None:
58
+ if not content:
59
+ return None
60
+ parts = []
61
+ for block in content:
62
+ text = block.get("text") if isinstance(block, Mapping) else getattr(block, "text", None)
63
+ if text:
64
+ parts.append(text)
65
+ return "\n".join(parts) if parts else None
66
+
67
+
68
+ def _serialize_for_bytes(value: Any) -> bytes:
69
+ if value is None:
70
+ return b""
71
+ dump = getattr(value, "model_dump_json", None)
72
+ if callable(dump):
73
+ return dump().encode()
74
+ return json.dumps(value, default=str).encode()
75
+
76
+
77
+ def instrument(
78
+ server: Any,
79
+ *,
80
+ server_name: str,
81
+ server_version: str | None = None,
82
+ sinks: Sequence[Sink] | None = None,
83
+ capture_arguments: bool = False,
84
+ redaction: RedactionConfig | None = None,
85
+ intent_capture: bool = False,
86
+ intent_capture_tools: Mapping[str, bool] | None = None,
87
+ resolve_identity: ResolveIdentity | None = None,
88
+ buffer_size: int = 20,
89
+ flush_interval_s: float = 5.0,
90
+ ) -> Any:
91
+ """Wrap `server` (an `MCPServer` or a low-level `Server`) so every tool
92
+ call is recorded as a `tool_call` event, per schema/events.md. Appends
93
+ one middleware function; call this once, right after constructing your
94
+ server. Returns the same instance, unmodified otherwise.
95
+ """
96
+ active_sinks: list[Sink] = list(sinks) if sinks else [ConsoleSink()]
97
+ buffer = EventBuffer(active_sinks, buffer_size=buffer_size, flush_interval_s=flush_interval_s)
98
+
99
+ async def _mcpsignals_middleware(ctx, call_next):
100
+ if ctx.method == "tools/list":
101
+ result = await call_next(ctx)
102
+ if intent_capture or intent_capture_tools:
103
+ # `call_next` returns a wire-shaped dict here (camelCase keys),
104
+ # not a ListToolsResult/Tool instance - verified against the
105
+ # installed mcp==2.0.0 package. Handle both shapes defensively.
106
+ if isinstance(result, Mapping):
107
+ for tool in result.get("tools") or []:
108
+ name = tool.get("name")
109
+ if enabled_for(
110
+ name, global_enabled=intent_capture, overrides=intent_capture_tools
111
+ ):
112
+ tool["inputSchema"] = inject_schema(tool.get("inputSchema"))
113
+ else:
114
+ for tool in getattr(result, "tools", None) or []:
115
+ if enabled_for(
116
+ tool.name, global_enabled=intent_capture, overrides=intent_capture_tools
117
+ ):
118
+ tool.input_schema = inject_schema(tool.input_schema)
119
+ return result
120
+
121
+ if ctx.method != "tools/call":
122
+ return await call_next(ctx)
123
+
124
+ params = ctx.params or {}
125
+ tool_name = params.get("name", "")
126
+ raw_arguments = params.get("arguments") or {}
127
+ request_bytes = len(json.dumps(raw_arguments, default=str).encode())
128
+
129
+ tool_intent_enabled = enabled_for(
130
+ tool_name, global_enabled=intent_capture, overrides=intent_capture_tools
131
+ )
132
+ if tool_intent_enabled:
133
+ clean_arguments, extracted = strip_injected(raw_arguments)
134
+ forwarded_params = dict(params)
135
+ forwarded_params["arguments"] = clean_arguments
136
+ forward_ctx = replace(ctx, params=forwarded_params)
137
+ else:
138
+ clean_arguments = raw_arguments
139
+ extracted = {"session_id": None, "agent_id": None, "intent": None}
140
+ forward_ctx = ctx
141
+
142
+ user_id: str | None = None
143
+ org_id: str | None = None
144
+ if resolve_identity is not None:
145
+ identity = resolve_identity(ctx)
146
+ if inspect.isawaitable(identity):
147
+ identity = await identity
148
+ if identity:
149
+ user_id, org_id = identity
150
+
151
+ client_name: str | None = None
152
+ client_version: str | None = None
153
+ client_params = getattr(ctx.session, "client_params", None)
154
+ client_info = getattr(client_params, "client_info", None) if client_params else None
155
+ if client_info is not None:
156
+ client_name = client_info.name
157
+ client_version = client_info.version
158
+
159
+ transport = "http" if getattr(ctx, "request", None) is not None else "stdio"
160
+
161
+ start = time.perf_counter()
162
+ error: BaseException | None = None
163
+ result = None
164
+ try:
165
+ result = await call_next(forward_ctx)
166
+ return result
167
+ except BaseException as exc:
168
+ error = exc
169
+ raise
170
+ finally:
171
+ duration_ms = int((time.perf_counter() - start) * 1000)
172
+ if error is not None:
173
+ success = False
174
+ error_message: str | None = str(error)[:2000]
175
+ response_bytes = 0
176
+ else:
177
+ success = not _is_error_result(result)
178
+ error_message = None if success else _content_to_text(_result_content(result))
179
+ if error_message:
180
+ error_message = error_message[:2000]
181
+ response_bytes = len(_serialize_for_bytes(result))
182
+
183
+ arguments = redact_arguments(clean_arguments, redaction) if capture_arguments else None
184
+
185
+ event = ToolCallEvent(
186
+ # Not datetime.UTC: that alias is 3.11+, and requires-python
187
+ # allows 3.10. Ruff's UP017 would rewrite this, which is why
188
+ # ruff.toml pins target-version to py310.
189
+ ts=datetime.now(timezone.utc),
190
+ server_name=server_name,
191
+ server_version=server_version,
192
+ tool_name=tool_name,
193
+ session_id=extracted.get("session_id"),
194
+ agent_id=extracted.get("agent_id"),
195
+ client_name=client_name,
196
+ client_version=client_version,
197
+ user_id=user_id,
198
+ org_id=org_id,
199
+ duration_ms=duration_ms,
200
+ success=success,
201
+ error_kind=classify_error(error_message),
202
+ error_message=error_message,
203
+ request_bytes=request_bytes,
204
+ response_bytes=response_bytes,
205
+ arguments=arguments,
206
+ intent=extracted.get("intent"),
207
+ transport=transport,
208
+ )
209
+ await buffer.add(event)
210
+
211
+ server.middleware.append(_mcpsignals_middleware)
212
+ return server