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.
Files changed (30) hide show
  1. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/PKG-INFO +55 -3
  2. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/README.md +54 -2
  3. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/pyproject.toml +1 -1
  4. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/__init__.py +3 -0
  5. mcpsignals-2.2.0/src/mcpsignals/buffer.py +149 -0
  6. mcpsignals-2.2.0/src/mcpsignals/handle.py +58 -0
  7. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/instrument.py +20 -10
  8. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/bigquery.py +10 -0
  9. mcpsignals-2.2.0/src/mcpsignals/sinks/console.py +28 -0
  10. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/otlp.py +9 -1
  11. mcpsignals-2.2.0/tests/test_bigquery.py +119 -0
  12. mcpsignals-2.2.0/tests/test_buffer.py +214 -0
  13. mcpsignals-2.2.0/tests/test_console.py +49 -0
  14. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_instrument.py +99 -1
  15. mcpsignals-2.2.0/tests/test_otlp.py +85 -0
  16. mcpsignals-2.0.2/src/mcpsignals/buffer.py +0 -98
  17. mcpsignals-2.0.2/src/mcpsignals/sinks/console.py +0 -17
  18. mcpsignals-2.0.2/tests/test_buffer.py +0 -80
  19. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/.gitignore +0 -0
  20. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/LICENSE +0 -0
  21. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/error_kind.py +0 -0
  22. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/events.py +0 -0
  23. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/intent_capture.py +0 -0
  24. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/redaction.py +0 -0
  25. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/__init__.py +0 -0
  26. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/base.py +0 -0
  27. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/src/mcpsignals/sinks/postgres.py +0 -0
  28. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_error_kind.py +0 -0
  29. {mcpsignals-2.0.2 → mcpsignals-2.2.0}/tests/test_intent_capture.py +0 -0
  30. {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.2
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. To write to your
67
- own warehouse instead:
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. To write to your
41
- own warehouse instead:
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.2"
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 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.
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()])