mcpsignals 2.0.2__tar.gz → 2.2.0__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-2.0.2 → mcpsignals-2.2.0}/PKG-INFO +55 -3
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/README.md +54 -2
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/pyproject.toml +1 -1
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/__init__.py +3 -0
- mcpsignals-2.2.0/src/mcpsignals/buffer.py +149 -0
- mcpsignals-2.2.0/src/mcpsignals/handle.py +58 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/instrument.py +20 -10
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/bigquery.py +10 -0
- mcpsignals-2.2.0/src/mcpsignals/sinks/console.py +28 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/otlp.py +9 -1
- mcpsignals-2.2.0/tests/test_bigquery.py +119 -0
- mcpsignals-2.2.0/tests/test_buffer.py +214 -0
- mcpsignals-2.2.0/tests/test_console.py +49 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_instrument.py +99 -1
- mcpsignals-2.2.0/tests/test_otlp.py +85 -0
- mcpsignals-2.0.2/src/mcpsignals/buffer.py +0 -98
- mcpsignals-2.0.2/src/mcpsignals/sinks/console.py +0 -17
- mcpsignals-2.0.2/tests/test_buffer.py +0 -80
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/.gitignore +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/LICENSE +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/error_kind.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/events.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/intent_capture.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/redaction.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/__init__.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/base.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/postgres.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_error_kind.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_intent_capture.py +0 -0
- {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_redaction.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcpsignals
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Drop-in instrumentation for MCP servers. Writes tool-call events to a database you own.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zentered-studios/mcpsignals#readme
|
|
6
6
|
Project-URL: Repository, https://github.com/zentered-studios/mcpsignals
|
|
@@ -63,8 +63,10 @@ on every request, so calling it before or after your `@server.tool()`
|
|
|
63
63
|
definitions makes no difference. The Node.js package differs here: it wraps
|
|
64
64
|
`registerTool`, so it has to run before any tool is registered.
|
|
65
65
|
|
|
66
|
-
With no sink configured it writes JSON lines to stdout.
|
|
67
|
-
|
|
66
|
+
With no sink configured it writes JSON lines to stdout. On a stdio
|
|
67
|
+
transport pass `sinks=[ConsoleSink(stream=sys.stderr)]`, because the MCP
|
|
68
|
+
spec reserves stdout for protocol messages. To write to your own warehouse
|
|
69
|
+
instead:
|
|
68
70
|
|
|
69
71
|
```python
|
|
70
72
|
from mcpsignals import instrument
|
|
@@ -80,6 +82,56 @@ Off by default: no tool arguments are recorded unless you opt in with
|
|
|
80
82
|
value types** are recorded, never values. See the root README's redaction
|
|
81
83
|
section before enabling this in anything handling real user data.
|
|
82
84
|
|
|
85
|
+
## Request-scoped runtimes and manual flushing
|
|
86
|
+
|
|
87
|
+
`instrument()` returns the server unchanged. `handle_for(server)` returns an
|
|
88
|
+
`InstrumentHandle` with two coroutines: `flush()` delivers everything
|
|
89
|
+
buffered so far to every sink, and `close()` does a final flush, cancels the
|
|
90
|
+
interval task, and unregisters the `atexit` hook. The handle is held in a
|
|
91
|
+
weak registry keyed by the exact server object, so it lives as long as the
|
|
92
|
+
server does; `handle_for()` returns `None` for a server that never went
|
|
93
|
+
through `instrument()`.
|
|
94
|
+
|
|
95
|
+
On a long-lived process, ignore the handle: the interval task and the
|
|
96
|
+
`atexit` hook flush for you. Neither is reliable on a request-scoped host
|
|
97
|
+
(a serverless function, or a server built fresh per request): the process
|
|
98
|
+
can be frozen or discarded right after the response is sent, and `atexit`
|
|
99
|
+
does not correspond to "this invocation is ending".
|
|
100
|
+
|
|
101
|
+
Pass `flush_interval_s=None` for manual mode. The buffer then skips both the
|
|
102
|
+
interval task and the `atexit` hook, so the host owns every flush:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from mcp.server.mcpserver import MCPServer
|
|
106
|
+
from mcpsignals import handle_for, instrument
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def handle_request(request):
|
|
110
|
+
server = MCPServer("my-server")
|
|
111
|
+
instrument(
|
|
112
|
+
server,
|
|
113
|
+
server_name="my-server",
|
|
114
|
+
sinks=[...],
|
|
115
|
+
flush_interval_s=None, # manual mode: the host flushes explicitly
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@server.tool()
|
|
119
|
+
def search(query: str) -> str:
|
|
120
|
+
return f"results for {query}"
|
|
121
|
+
|
|
122
|
+
response = await serve(request, server)
|
|
123
|
+
|
|
124
|
+
await handle_for(server).flush() # before returning the response
|
|
125
|
+
return response
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Call `close()` instead of `flush()` when the server is done for good: at the
|
|
129
|
+
end of a test, or when a per-request server is discarded. In the default
|
|
130
|
+
interval mode, `close()` is also what stops the interval task and removes the
|
|
131
|
+
`atexit` hook, so a test suite that instruments many servers does not
|
|
132
|
+
accumulate either. Both `flush()` and `close()` are safe to await more than
|
|
133
|
+
once.
|
|
134
|
+
|
|
83
135
|
## Known limitation: `session_id`
|
|
84
136
|
|
|
85
137
|
`mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
|
|
@@ -37,8 +37,10 @@ on every request, so calling it before or after your `@server.tool()`
|
|
|
37
37
|
definitions makes no difference. The Node.js package differs here: it wraps
|
|
38
38
|
`registerTool`, so it has to run before any tool is registered.
|
|
39
39
|
|
|
40
|
-
With no sink configured it writes JSON lines to stdout.
|
|
41
|
-
|
|
40
|
+
With no sink configured it writes JSON lines to stdout. On a stdio
|
|
41
|
+
transport pass `sinks=[ConsoleSink(stream=sys.stderr)]`, because the MCP
|
|
42
|
+
spec reserves stdout for protocol messages. To write to your own warehouse
|
|
43
|
+
instead:
|
|
42
44
|
|
|
43
45
|
```python
|
|
44
46
|
from mcpsignals import instrument
|
|
@@ -54,6 +56,56 @@ Off by default: no tool arguments are recorded unless you opt in with
|
|
|
54
56
|
value types** are recorded, never values. See the root README's redaction
|
|
55
57
|
section before enabling this in anything handling real user data.
|
|
56
58
|
|
|
59
|
+
## Request-scoped runtimes and manual flushing
|
|
60
|
+
|
|
61
|
+
`instrument()` returns the server unchanged. `handle_for(server)` returns an
|
|
62
|
+
`InstrumentHandle` with two coroutines: `flush()` delivers everything
|
|
63
|
+
buffered so far to every sink, and `close()` does a final flush, cancels the
|
|
64
|
+
interval task, and unregisters the `atexit` hook. The handle is held in a
|
|
65
|
+
weak registry keyed by the exact server object, so it lives as long as the
|
|
66
|
+
server does; `handle_for()` returns `None` for a server that never went
|
|
67
|
+
through `instrument()`.
|
|
68
|
+
|
|
69
|
+
On a long-lived process, ignore the handle: the interval task and the
|
|
70
|
+
`atexit` hook flush for you. Neither is reliable on a request-scoped host
|
|
71
|
+
(a serverless function, or a server built fresh per request): the process
|
|
72
|
+
can be frozen or discarded right after the response is sent, and `atexit`
|
|
73
|
+
does not correspond to "this invocation is ending".
|
|
74
|
+
|
|
75
|
+
Pass `flush_interval_s=None` for manual mode. The buffer then skips both the
|
|
76
|
+
interval task and the `atexit` hook, so the host owns every flush:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from mcp.server.mcpserver import MCPServer
|
|
80
|
+
from mcpsignals import handle_for, instrument
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def handle_request(request):
|
|
84
|
+
server = MCPServer("my-server")
|
|
85
|
+
instrument(
|
|
86
|
+
server,
|
|
87
|
+
server_name="my-server",
|
|
88
|
+
sinks=[...],
|
|
89
|
+
flush_interval_s=None, # manual mode: the host flushes explicitly
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@server.tool()
|
|
93
|
+
def search(query: str) -> str:
|
|
94
|
+
return f"results for {query}"
|
|
95
|
+
|
|
96
|
+
response = await serve(request, server)
|
|
97
|
+
|
|
98
|
+
await handle_for(server).flush() # before returning the response
|
|
99
|
+
return response
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Call `close()` instead of `flush()` when the server is done for good: at the
|
|
103
|
+
end of a test, or when a per-request server is discarded. In the default
|
|
104
|
+
interval mode, `close()` is also what stops the interval task and removes the
|
|
105
|
+
`atexit` hook, so a test suite that instruments many servers does not
|
|
106
|
+
accumulate either. Both `flush()` and `close()` are safe to await more than
|
|
107
|
+
once.
|
|
108
|
+
|
|
57
109
|
## Known limitation: `session_id`
|
|
58
110
|
|
|
59
111
|
`mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
|
|
@@ -8,7 +8,7 @@ name = "mcpsignals"
|
|
|
8
8
|
# stamps the real version in at release time and never commits it back, exactly
|
|
9
9
|
# as packages/node/package.json is left at 0.1.0. Do not read this as the
|
|
10
10
|
# published version - check PyPI or the git tags for that.
|
|
11
|
-
version = "2.0
|
|
11
|
+
version = "2.2.0"
|
|
12
12
|
description = "Drop-in instrumentation for MCP servers. Writes tool-call events to a database you own."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
license = "MIT"
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
|
|
2
|
+
from mcpsignals.handle import InstrumentHandle, handle_for
|
|
2
3
|
from mcpsignals.instrument import instrument
|
|
3
4
|
from mcpsignals.redaction import RedactionConfig
|
|
4
5
|
|
|
5
6
|
__all__ = [
|
|
6
7
|
"instrument",
|
|
8
|
+
"handle_for",
|
|
9
|
+
"InstrumentHandle",
|
|
7
10
|
"RedactionConfig",
|
|
8
11
|
"ToolCallEvent",
|
|
9
12
|
"SessionSummaryEvent",
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
Pass `flush_interval_s=None` for manual mode: no interval task, no `atexit`
|
|
7
|
+
hook, the caller flushes explicitly (via `handle_for(server).flush()`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import atexit
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
|
|
16
|
+
from mcpsignals.sinks.base import Sink
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("mcpsignals")
|
|
19
|
+
|
|
20
|
+
Event = ToolCallEvent | SessionSummaryEvent
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EventBuffer:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
sinks: Sequence[Sink],
|
|
27
|
+
buffer_size: int = 20,
|
|
28
|
+
flush_interval_s: float | None = 5.0,
|
|
29
|
+
):
|
|
30
|
+
self._sinks = list(sinks)
|
|
31
|
+
self._buffer_size = buffer_size
|
|
32
|
+
self._flush_interval_s = flush_interval_s
|
|
33
|
+
self._events: list[Event] = []
|
|
34
|
+
self._lock = asyncio.Lock()
|
|
35
|
+
self._warned_sinks: set[int] = set()
|
|
36
|
+
self._interval_task: asyncio.Task | None = None
|
|
37
|
+
# The interval task's current flush, if one is in progress. Kept
|
|
38
|
+
# outside the task (and shielded from its cancellation) so close()
|
|
39
|
+
# can wait for a write that already left the buffer.
|
|
40
|
+
self._inflight: asyncio.Future | None = None
|
|
41
|
+
# Manual mode (`flush_interval_s=None`) never starts the interval task
|
|
42
|
+
# and never registers the atexit hook. `stop()` flips this too, so a
|
|
43
|
+
# later `add()` cannot silently restart the task.
|
|
44
|
+
self._stopped = flush_interval_s is None
|
|
45
|
+
if flush_interval_s is not None:
|
|
46
|
+
atexit.register(self._atexit_flush)
|
|
47
|
+
|
|
48
|
+
def _ensure_interval_task(self) -> None:
|
|
49
|
+
# Started lazily on first event, from inside a running event loop -
|
|
50
|
+
# constructing EventBuffer itself must not require a loop to exist yet.
|
|
51
|
+
if self._stopped or self._interval_task is not None:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
loop = asyncio.get_running_loop()
|
|
55
|
+
except RuntimeError:
|
|
56
|
+
return
|
|
57
|
+
self._interval_task = loop.create_task(self._interval_loop())
|
|
58
|
+
|
|
59
|
+
async def _interval_loop(self) -> None:
|
|
60
|
+
while True:
|
|
61
|
+
await asyncio.sleep(self._flush_interval_s) # type: ignore[arg-type]
|
|
62
|
+
# Shielded: cancelling this task (stop()/close()) must not abort a
|
|
63
|
+
# sink write whose batch has already been popped from the buffer.
|
|
64
|
+
# CancelledError is a BaseException, so _write_to_sink's `except
|
|
65
|
+
# Exception` would not catch it and the batch would be lost.
|
|
66
|
+
self._inflight = asyncio.ensure_future(self.flush())
|
|
67
|
+
await asyncio.shield(self._inflight)
|
|
68
|
+
|
|
69
|
+
async def add(self, event: Event) -> None:
|
|
70
|
+
self._ensure_interval_task()
|
|
71
|
+
async with self._lock:
|
|
72
|
+
self._events.append(event)
|
|
73
|
+
should_flush = len(self._events) >= self._buffer_size
|
|
74
|
+
if should_flush:
|
|
75
|
+
await self.flush()
|
|
76
|
+
|
|
77
|
+
async def flush(self) -> None:
|
|
78
|
+
async with self._lock:
|
|
79
|
+
if not self._events:
|
|
80
|
+
return
|
|
81
|
+
batch, self._events = self._events, []
|
|
82
|
+
|
|
83
|
+
# Each _write_to_sink swallows and logs its own failure, so nothing
|
|
84
|
+
# here needs to inspect the results.
|
|
85
|
+
await asyncio.gather(
|
|
86
|
+
*(self._write_to_sink(sink, batch) for sink in self._sinks),
|
|
87
|
+
return_exceptions=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def stop(self) -> None:
|
|
91
|
+
"""Cancel the interval task (if one is running) and unregister the
|
|
92
|
+
atexit hook. Safe to call in manual mode, and safe to call twice.
|
|
93
|
+
Does not flush, and leaves an in-flight interval write untouched:
|
|
94
|
+
use `close()` for a final flush plus stop.
|
|
95
|
+
"""
|
|
96
|
+
self._stopped = True
|
|
97
|
+
if self._interval_task is not None:
|
|
98
|
+
self._interval_task.cancel()
|
|
99
|
+
self._interval_task = None
|
|
100
|
+
# No-op when the hook was never registered (manual mode, or an
|
|
101
|
+
# earlier stop() already removed it).
|
|
102
|
+
atexit.unregister(self._atexit_flush)
|
|
103
|
+
|
|
104
|
+
async def close(self) -> None:
|
|
105
|
+
"""`stop()`, wait for the interval task and any write it already had
|
|
106
|
+
in flight to settle, then a final `flush()`. Nothing is left running
|
|
107
|
+
on the loop and no popped batch is dropped. Idempotent.
|
|
108
|
+
"""
|
|
109
|
+
task = self._interval_task
|
|
110
|
+
self.stop()
|
|
111
|
+
if task is not None:
|
|
112
|
+
# Not `suppress(CancelledError)` around `await task`: a
|
|
113
|
+
# cancellation aimed at close() itself surfaces at that same await
|
|
114
|
+
# and would be swallowed too. gather(return_exceptions=True) hands
|
|
115
|
+
# the interval task's own CancelledError back as a value while a
|
|
116
|
+
# cancel of close() still raises out of the await. (3.10-safe:
|
|
117
|
+
# `Task.cancelling()` is 3.11+.)
|
|
118
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
119
|
+
inflight = self._inflight
|
|
120
|
+
if inflight is not None and not inflight.done():
|
|
121
|
+
# Shielded again so cancelling close() itself lets the write finish.
|
|
122
|
+
await asyncio.shield(inflight)
|
|
123
|
+
await self.flush()
|
|
124
|
+
|
|
125
|
+
async def _write_to_sink(self, sink: Sink, batch: list[Event]) -> None:
|
|
126
|
+
try:
|
|
127
|
+
await sink.write(batch)
|
|
128
|
+
except Exception as exc: # noqa: BLE001 - a sink must never break the host server
|
|
129
|
+
sink_id = id(sink)
|
|
130
|
+
if sink_id not in self._warned_sinks:
|
|
131
|
+
self._warned_sinks.add(sink_id)
|
|
132
|
+
logger.error("mcpsignals: sink %r failed, dropping batch: %s", sink, exc)
|
|
133
|
+
|
|
134
|
+
def _atexit_flush(self) -> None:
|
|
135
|
+
if not self._events:
|
|
136
|
+
return
|
|
137
|
+
# Best-effort only: there is no guarantee a loop is available to
|
|
138
|
+
# await a real flush at interpreter shutdown. Callers that need a
|
|
139
|
+
# guaranteed flush should `await handle_for(server).flush()` (or
|
|
140
|
+
# `.close()`) explicitly before shutting down.
|
|
141
|
+
try:
|
|
142
|
+
asyncio.run(self.flush())
|
|
143
|
+
except RuntimeError:
|
|
144
|
+
logger.warning(
|
|
145
|
+
"mcpsignals: %d buffered event(s) dropped at exit (no event loop available "
|
|
146
|
+
"to flush) - call `await handle_for(server).flush()` explicitly before "
|
|
147
|
+
"shutdown for a guarantee",
|
|
148
|
+
len(self._events),
|
|
149
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The host's way to reach the buffer behind an instrumented server.
|
|
2
|
+
|
|
3
|
+
`instrument()` keeps returning the server object unchanged (so existing
|
|
4
|
+
callers are not affected); the handle is looked up separately through
|
|
5
|
+
`handle_for(server)`. The registry is a `weakref.WeakKeyDictionary` keyed
|
|
6
|
+
by the exact server object passed to `instrument()`: the handle lives
|
|
7
|
+
exactly as long as that server object does and is dropped with it, so a
|
|
8
|
+
per-request server does not leak its buffer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import weakref
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mcpsignals.buffer import EventBuffer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InstrumentHandle:
|
|
18
|
+
"""Returned by `handle_for(server)` for a server that went through
|
|
19
|
+
`instrument()`. `flush()` delivers everything buffered so far to every
|
|
20
|
+
sink; `close()` does a final flush, cancels the interval task, and
|
|
21
|
+
unregisters the atexit hook. Both are safe to await more than once.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, buffer: EventBuffer):
|
|
25
|
+
self._buffer = buffer
|
|
26
|
+
|
|
27
|
+
async def flush(self) -> None:
|
|
28
|
+
"""Flush buffered events to every sink now. On a request-scoped
|
|
29
|
+
runtime, or in manual mode (`flush_interval_s=None`), call this
|
|
30
|
+
before returning the response.
|
|
31
|
+
"""
|
|
32
|
+
await self._buffer.flush()
|
|
33
|
+
|
|
34
|
+
async def close(self) -> None:
|
|
35
|
+
"""Final flush, then stop: cancel the interval task if one is
|
|
36
|
+
running and unregister the atexit hook. Call this when the server
|
|
37
|
+
is done for good - at the end of a test, or when a per-request
|
|
38
|
+
server is discarded. Idempotent.
|
|
39
|
+
"""
|
|
40
|
+
await self._buffer.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_registry: weakref.WeakKeyDictionary[Any, InstrumentHandle] = weakref.WeakKeyDictionary()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def register(server: Any, handle: InstrumentHandle) -> None:
|
|
47
|
+
_registry[server] = handle
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def handle_for(server: Any) -> InstrumentHandle | None:
|
|
51
|
+
"""The `InstrumentHandle` for `server`, or `None` if that exact object
|
|
52
|
+
never went through `instrument()`.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return _registry.get(server)
|
|
56
|
+
except TypeError:
|
|
57
|
+
# Not hashable or not weakly referenceable: cannot have been registered.
|
|
58
|
+
return None
|
|
@@ -5,15 +5,17 @@ present on both `MCPServer` and the low-level `Server` - the same mechanism
|
|
|
5
5
|
works for both, no separate code paths needed. See
|
|
6
6
|
https://py.sdk.modelcontextprotocol.io/v2/advanced/middleware/.
|
|
7
7
|
|
|
8
|
-
Known SDK limitation (verified against
|
|
9
|
-
mcp/server/context.py):
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
Known SDK limitation (first verified against mcp==2.0.0, re-checked against
|
|
9
|
+
the mcp==2.2.0 that a fresh `uv sync` resolves; mcp/server/context.py):
|
|
10
|
+
`ServerRequestContext` - what middleware receives - does not publicly expose
|
|
11
|
+
the transport's connection-level session id or a `connection` accessor
|
|
12
|
+
(only the handler-facing `Context` class does, via a private `Connection`
|
|
13
|
+
it doesn't share with middleware; `ctx.session` is a `ServerSession`, which
|
|
14
|
+
has no session id either). We do not reach into that private attribute. As
|
|
15
|
+
a result `session_id` on emitted events is only ever populated from the
|
|
16
|
+
optional intent-capture value the calling agent supplies - it is `None` for
|
|
17
|
+
calls where intent capture is off or the caller didn't pass one, even though
|
|
18
|
+
the connection may well have a real session id.
|
|
17
19
|
Track https://github.com/modelcontextprotocol/python-sdk for this being
|
|
18
20
|
exposed to middleware in a future release.
|
|
19
21
|
"""
|
|
@@ -29,6 +31,8 @@ from typing import Any
|
|
|
29
31
|
from mcpsignals.buffer import EventBuffer
|
|
30
32
|
from mcpsignals.error_kind import classify_error
|
|
31
33
|
from mcpsignals.events import ToolCallEvent
|
|
34
|
+
from mcpsignals.handle import InstrumentHandle
|
|
35
|
+
from mcpsignals.handle import register as _register_handle
|
|
32
36
|
from mcpsignals.intent_capture import enabled_for, inject_schema, strip_injected
|
|
33
37
|
from mcpsignals.redaction import RedactionConfig, redact_arguments
|
|
34
38
|
from mcpsignals.sinks.base import Sink
|
|
@@ -86,15 +90,21 @@ def instrument(
|
|
|
86
90
|
intent_capture_tools: Mapping[str, bool] | None = None,
|
|
87
91
|
resolve_identity: ResolveIdentity | None = None,
|
|
88
92
|
buffer_size: int = 20,
|
|
89
|
-
flush_interval_s: float = 5.0,
|
|
93
|
+
flush_interval_s: float | None = 5.0,
|
|
90
94
|
) -> Any:
|
|
91
95
|
"""Wrap `server` (an `MCPServer` or a low-level `Server`) so every tool
|
|
92
96
|
call is recorded as a `tool_call` event, per schema/events.md. Appends
|
|
93
97
|
one middleware function; call this once, right after constructing your
|
|
94
98
|
server. Returns the same instance, unmodified otherwise.
|
|
99
|
+
|
|
100
|
+
`handle_for(server)` then returns an `InstrumentHandle` with `flush()`
|
|
101
|
+
and `close()`. Pass `flush_interval_s=None` for manual mode: no interval
|
|
102
|
+
task and no atexit hook, so the host flushes explicitly through the
|
|
103
|
+
handle (request-scoped runtimes, tests, per-request servers).
|
|
95
104
|
"""
|
|
96
105
|
active_sinks: list[Sink] = list(sinks) if sinks else [ConsoleSink()]
|
|
97
106
|
buffer = EventBuffer(active_sinks, buffer_size=buffer_size, flush_interval_s=flush_interval_s)
|
|
107
|
+
_register_handle(server, InstrumentHandle(buffer))
|
|
98
108
|
|
|
99
109
|
async def _mcpsignals_middleware(ctx, call_next):
|
|
100
110
|
if ctx.method == "tools/list":
|
|
@@ -3,10 +3,16 @@ Credentials come from Application Default Credentials, same as the client
|
|
|
3
3
|
library's own defaults - never an invented config file. The client library
|
|
4
4
|
is synchronous, so writes run in a thread via asyncio.to_thread and never
|
|
5
5
|
block the event loop.
|
|
6
|
+
|
|
7
|
+
Rows go through `insert_rows_json` (`tabledata.insertAll`), which applies no
|
|
8
|
+
type conversion. A `JSON` column expects a JSON string on that path, so
|
|
9
|
+
`arguments` is encoded with `json.dumps` before insert - the same thing the
|
|
10
|
+
client library's own `insert_rows` does for JSON fields (`_helpers._json_to_json`).
|
|
6
11
|
"""
|
|
7
12
|
|
|
8
13
|
import asyncio
|
|
9
14
|
import dataclasses
|
|
15
|
+
import json
|
|
10
16
|
|
|
11
17
|
from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
|
|
12
18
|
|
|
@@ -28,6 +34,10 @@ class BigQuerySink:
|
|
|
28
34
|
row = dataclasses.asdict(event)
|
|
29
35
|
row.pop("event_type", None)
|
|
30
36
|
row["ts"] = event.ts.isoformat() if event.ts else None
|
|
37
|
+
if isinstance(event, ToolCallEvent):
|
|
38
|
+
row["arguments"] = (
|
|
39
|
+
json.dumps(row["arguments"]) if row["arguments"] is not None else None
|
|
40
|
+
)
|
|
31
41
|
return row
|
|
32
42
|
|
|
33
43
|
def _insert_sync(self, table: str, rows: list[dict]) -> None:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TextIO
|
|
5
|
+
|
|
6
|
+
from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConsoleSink:
|
|
10
|
+
"""The default sink. Zero configuration, writes one JSON line per event
|
|
11
|
+
to stdout so the library is useful without setting up a warehouse.
|
|
12
|
+
|
|
13
|
+
`stream` picks where the lines go. `None` (the default) resolves to
|
|
14
|
+
`sys.stdout` at write time, so output redirection and test capture that
|
|
15
|
+
replace `sys.stdout` still apply. On a stdio transport, stdout is the MCP
|
|
16
|
+
wire: the spec says the server MUST NOT write anything to stdout that is
|
|
17
|
+
not a valid MCP message, and MAY log to stderr. Pass `sys.stderr` there.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
21
|
+
self._stream = stream
|
|
22
|
+
|
|
23
|
+
async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
|
|
24
|
+
stream = self._stream if self._stream is not None else sys.stdout
|
|
25
|
+
for event in events:
|
|
26
|
+
payload = dataclasses.asdict(event)
|
|
27
|
+
payload["ts"] = event.ts.isoformat() if event.ts else None
|
|
28
|
+
print(json.dumps(payload, default=str), file=stream, flush=True)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""OTLP sink. Requires the `otlp` extra (opentelemetry-api only, not an SDK
|
|
2
2
|
or exporter - relies on whatever global TracerProvider the host app already
|
|
3
|
-
configured, the standard "zero-code" OTel pattern).
|
|
3
|
+
configured, the standard "zero-code" OTel pattern). Emits one root span per
|
|
4
|
+
tool call; spans are never parented to the context current at flush time
|
|
5
|
+
(see the note at `start_span` in `write`).
|
|
4
6
|
|
|
5
7
|
Field mapping verified against the live OpenTelemetry GenAI semantic
|
|
6
8
|
conventions for MCP (open-telemetry/semantic-conventions-genai,
|
|
@@ -24,6 +26,7 @@ class OtlpSink:
|
|
|
24
26
|
self._tracer = tracer
|
|
25
27
|
|
|
26
28
|
async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
|
|
29
|
+
from opentelemetry.context import Context
|
|
27
30
|
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
28
31
|
|
|
29
32
|
for event in events:
|
|
@@ -71,8 +74,13 @@ class OtlpSink:
|
|
|
71
74
|
start_ns = int(event.ts.timestamp() * 1_000_000_000) if event.ts else None
|
|
72
75
|
end_ns = start_ns + event.duration_ms * 1_000_000 if start_ns is not None else None
|
|
73
76
|
|
|
77
|
+
# The batch is usually written from inside whatever request handler
|
|
78
|
+
# pushed the last event, so the current context (the default
|
|
79
|
+
# parent) belongs to an unrelated span. Start from an empty
|
|
80
|
+
# Context so every tool call is its own root span.
|
|
74
81
|
span = self._tracer.start_span(
|
|
75
82
|
f"tools/call {event.tool_name}",
|
|
83
|
+
context=Context(),
|
|
76
84
|
kind=SpanKind.SERVER,
|
|
77
85
|
attributes=attributes,
|
|
78
86
|
start_time=start_ns,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
|
|
6
|
+
from mcpsignals.sinks.bigquery import BigQuerySink
|
|
7
|
+
|
|
8
|
+
TS = datetime(2026, 9, 1, 23, 25, 24, tzinfo=timezone.utc)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeClient:
|
|
12
|
+
"""Records insert_rows_json(table_ref, rows) calls. Returns `errors` (an
|
|
13
|
+
empty list means success, matching google-cloud-bigquery's contract)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, errors=None):
|
|
16
|
+
self.calls: list[tuple[str, list[dict]]] = []
|
|
17
|
+
self.errors = errors if errors is not None else []
|
|
18
|
+
|
|
19
|
+
def insert_rows_json(self, table_ref, rows):
|
|
20
|
+
self.calls.append((table_ref, rows))
|
|
21
|
+
return self.errors
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_tool_call(**overrides) -> ToolCallEvent:
|
|
25
|
+
defaults = dict(ts=TS, server_name="s", tool_name="my-tool", duration_ms=5)
|
|
26
|
+
defaults.update(overrides)
|
|
27
|
+
return ToolCallEvent(**defaults)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_session_summary(**overrides) -> SessionSummaryEvent:
|
|
31
|
+
defaults = dict(ts=TS, session_id="sess-1", server_name="s", call_count=1)
|
|
32
|
+
defaults.update(overrides)
|
|
33
|
+
return SessionSummaryEvent(**defaults)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_tool_call_rows_go_to_the_tool_call_table():
|
|
37
|
+
client = FakeClient()
|
|
38
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
39
|
+
|
|
40
|
+
await sink.write([make_tool_call()])
|
|
41
|
+
|
|
42
|
+
assert [table_ref for table_ref, _ in client.calls] == ["d.tool_call"]
|
|
43
|
+
assert len(client.calls[0][1]) == 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_session_summary_rows_go_to_the_session_summary_table():
|
|
47
|
+
client = FakeClient()
|
|
48
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
49
|
+
|
|
50
|
+
await sink.write([make_session_summary()])
|
|
51
|
+
|
|
52
|
+
assert [table_ref for table_ref, _ in client.calls] == ["d.session_summary"]
|
|
53
|
+
row = client.calls[0][1][0]
|
|
54
|
+
assert row["session_id"] == "sess-1"
|
|
55
|
+
assert "event_type" not in row
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_arguments_are_sent_as_a_json_string_for_the_json_column():
|
|
59
|
+
# tabledata.insertAll expects a JSON-typed column as a JSON string, which
|
|
60
|
+
# is what google-cloud-bigquery's own insert_rows() does for JSON fields
|
|
61
|
+
# (_helpers._json_to_json). insert_rows_json applies no conversion, so the
|
|
62
|
+
# sink has to encode the dict itself.
|
|
63
|
+
client = FakeClient()
|
|
64
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
65
|
+
event = make_tool_call(arguments={"a": 1, "nested": {"b": [1, 2]}})
|
|
66
|
+
|
|
67
|
+
await sink.write([event])
|
|
68
|
+
|
|
69
|
+
row = client.calls[0][1][0]
|
|
70
|
+
assert isinstance(row["arguments"], str)
|
|
71
|
+
assert row["arguments"] == json.dumps(event.arguments)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_null_arguments_stay_null():
|
|
75
|
+
client = FakeClient()
|
|
76
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
77
|
+
|
|
78
|
+
await sink.write([make_tool_call(arguments=None)])
|
|
79
|
+
|
|
80
|
+
assert client.calls[0][1][0]["arguments"] is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_ts_is_iso_formatted_and_event_type_is_dropped():
|
|
84
|
+
client = FakeClient()
|
|
85
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
86
|
+
|
|
87
|
+
await sink.write([make_tool_call()])
|
|
88
|
+
|
|
89
|
+
row = client.calls[0][1][0]
|
|
90
|
+
assert row["ts"] == "2026-09-01T23:25:24+00:00"
|
|
91
|
+
assert "event_type" not in row
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_mixed_batch_makes_one_insert_per_event_type():
|
|
95
|
+
client = FakeClient()
|
|
96
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
97
|
+
|
|
98
|
+
await sink.write([make_tool_call(), make_session_summary(), make_tool_call(tool_name="other")])
|
|
99
|
+
|
|
100
|
+
assert [table_ref for table_ref, _ in client.calls] == ["d.tool_call", "d.session_summary"]
|
|
101
|
+
assert len(client.calls[0][1]) == 2
|
|
102
|
+
assert len(client.calls[1][1]) == 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_empty_batch_never_calls_the_client():
|
|
106
|
+
client = FakeClient()
|
|
107
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
108
|
+
|
|
109
|
+
await sink.write([])
|
|
110
|
+
|
|
111
|
+
assert client.calls == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_insert_errors_raise():
|
|
115
|
+
client = FakeClient(errors=[{"index": 0, "errors": [{"reason": "invalid"}]}])
|
|
116
|
+
sink = BigQuerySink(dataset="d", client=client)
|
|
117
|
+
|
|
118
|
+
with pytest.raises(RuntimeError, match="d.tool_call"):
|
|
119
|
+
await sink.write([make_tool_call()])
|