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.
- mcpsignals-1.0.1/.gitignore +9 -0
- mcpsignals-1.0.1/LICENSE +21 -0
- mcpsignals-1.0.1/PKG-INFO +98 -0
- mcpsignals-1.0.1/README.md +72 -0
- mcpsignals-1.0.1/pyproject.toml +52 -0
- mcpsignals-1.0.1/src/mcpsignals/__init__.py +10 -0
- mcpsignals-1.0.1/src/mcpsignals/buffer.py +98 -0
- mcpsignals-1.0.1/src/mcpsignals/error_kind.py +26 -0
- mcpsignals-1.0.1/src/mcpsignals/events.py +44 -0
- mcpsignals-1.0.1/src/mcpsignals/instrument.py +212 -0
- mcpsignals-1.0.1/src/mcpsignals/intent_capture.py +62 -0
- mcpsignals-1.0.1/src/mcpsignals/redaction.py +52 -0
- mcpsignals-1.0.1/src/mcpsignals/sinks/__init__.py +25 -0
- mcpsignals-1.0.1/src/mcpsignals/sinks/base.py +13 -0
- mcpsignals-1.0.1/src/mcpsignals/sinks/bigquery.py +47 -0
- mcpsignals-1.0.1/src/mcpsignals/sinks/console.py +17 -0
- mcpsignals-1.0.1/src/mcpsignals/sinks/otlp.py +86 -0
- mcpsignals-1.0.1/src/mcpsignals/sinks/postgres.py +115 -0
- mcpsignals-1.0.1/tests/test_buffer.py +80 -0
- mcpsignals-1.0.1/tests/test_error_kind.py +42 -0
- mcpsignals-1.0.1/tests/test_instrument.py +205 -0
- mcpsignals-1.0.1/tests/test_redaction.py +45 -0
mcpsignals-1.0.1/LICENSE
ADDED
|
@@ -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
|