mcpsignals 2.2.0__tar.gz → 2.3.2__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 (31) hide show
  1. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/PKG-INFO +59 -1
  2. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/README.md +58 -0
  3. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/pyproject.toml +2 -2
  4. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/__init__.py +1 -2
  5. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/buffer.py +1 -3
  6. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/events.py +4 -13
  7. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/instrument.py +131 -30
  8. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/intent_capture.py +22 -15
  9. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/redaction.py +9 -1
  10. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/base.py +2 -2
  11. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/bigquery.py +3 -9
  12. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/console.py +2 -2
  13. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/otlp.py +2 -5
  14. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/postgres.py +10 -37
  15. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_bigquery.py +4 -23
  16. mcpsignals-2.3.2/tests/test_instrument.py +718 -0
  17. mcpsignals-2.3.2/tests/test_otlp.py +206 -0
  18. mcpsignals-2.3.2/tests/test_postgres.py +155 -0
  19. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_redaction.py +6 -6
  20. mcpsignals-2.3.2/uv.lock +1649 -0
  21. mcpsignals-2.2.0/tests/test_instrument.py +0 -360
  22. mcpsignals-2.2.0/tests/test_otlp.py +0 -85
  23. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/.gitignore +0 -0
  24. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/LICENSE +0 -0
  25. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/error_kind.py +0 -0
  26. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/handle.py +0 -0
  27. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/__init__.py +0 -0
  28. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_buffer.py +0 -0
  29. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_console.py +0 -0
  30. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_error_kind.py +0 -0
  31. {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_intent_capture.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcpsignals
3
- Version: 2.2.0
3
+ Version: 2.3.2
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
@@ -82,6 +82,64 @@ Off by default: no tool arguments are recorded unless you opt in with
82
82
  value types** are recorded, never values. See the root README's redaction
83
83
  section before enabling this in anything handling real user data.
84
84
 
85
+ ## Identity: `user_id` and `org_id`
86
+
87
+ `resolve_identity` is the only thing that ever fills in `user_id` and
88
+ `org_id`. Without it both stay `None` on every event.
89
+
90
+ ```python
91
+ def resolve_identity(ctx):
92
+ account = look_up_account(ctx) # your auth layer, not ours
93
+ return (account.id, account.org_id) if account else (None, None)
94
+
95
+
96
+ instrument(server, server_name="my-server", resolve_identity=resolve_identity)
97
+ ```
98
+
99
+ It receives the middleware's `ServerRequestContext` and returns a
100
+ `(user_id, org_id)` tuple. It may be a plain function or a coroutine;
101
+ either is awaited correctly. Returning `(None, None)` records a null
102
+ identity, which is what the column means for an anonymous call.
103
+
104
+ The Node.js package differs here: it passes `{ sessionId }` rather than the
105
+ raw context, and returns an object. Porting a resolver between the two means
106
+ rewriting both ends. Note that the context Python hands you carries no
107
+ session id of its own, for the reason in the limitation below.
108
+
109
+ It runs **after** your handler settles, so a slow resolver never lands in
110
+ `duration_ms` (wall time from call start to response, per
111
+ [`schema/events.md`](../../schema/events.md)). A resolver that raises is
112
+ logged once and costs you the identity on that event, nothing else: the
113
+ event is still recorded, and your handler's result or exception reaches the
114
+ client untouched.
115
+
116
+ ## Buffering and flush timing
117
+
118
+ Events are batched in memory rather than written one per call.
119
+ `buffer_size` (default 20) flushes after that many events;
120
+ `flush_interval_s` (default 5.0) flushes on a timer. Whichever comes first
121
+ wins, plus a best-effort `atexit` flush.
122
+
123
+ ```python
124
+ instrument(
125
+ server,
126
+ server_name="my-server",
127
+ sinks=[PostgresSink()],
128
+ buffer_size=100, # fewer, larger writes
129
+ flush_interval_s=10.0,
130
+ )
131
+ ```
132
+
133
+ Raising `buffer_size` trades memory and worst-case loss for fewer round
134
+ trips: a crash loses whatever is still buffered.
135
+
136
+ The `atexit` flush is best-effort only. It needs an event loop that may not
137
+ exist at interpreter shutdown, and it logs a warning and drops the buffer
138
+ when there is none. Anything that must not be lost should go through an
139
+ explicit `await handle_for(server).flush()` before you shut down.
140
+
141
+ `flush_interval_s=None` is manual mode, covered next.
142
+
85
143
  ## Request-scoped runtimes and manual flushing
86
144
 
87
145
  `instrument()` returns the server unchanged. `handle_for(server)` returns an
@@ -56,6 +56,64 @@ Off by default: no tool arguments are recorded unless you opt in with
56
56
  value types** are recorded, never values. See the root README's redaction
57
57
  section before enabling this in anything handling real user data.
58
58
 
59
+ ## Identity: `user_id` and `org_id`
60
+
61
+ `resolve_identity` is the only thing that ever fills in `user_id` and
62
+ `org_id`. Without it both stay `None` on every event.
63
+
64
+ ```python
65
+ def resolve_identity(ctx):
66
+ account = look_up_account(ctx) # your auth layer, not ours
67
+ return (account.id, account.org_id) if account else (None, None)
68
+
69
+
70
+ instrument(server, server_name="my-server", resolve_identity=resolve_identity)
71
+ ```
72
+
73
+ It receives the middleware's `ServerRequestContext` and returns a
74
+ `(user_id, org_id)` tuple. It may be a plain function or a coroutine;
75
+ either is awaited correctly. Returning `(None, None)` records a null
76
+ identity, which is what the column means for an anonymous call.
77
+
78
+ The Node.js package differs here: it passes `{ sessionId }` rather than the
79
+ raw context, and returns an object. Porting a resolver between the two means
80
+ rewriting both ends. Note that the context Python hands you carries no
81
+ session id of its own, for the reason in the limitation below.
82
+
83
+ It runs **after** your handler settles, so a slow resolver never lands in
84
+ `duration_ms` (wall time from call start to response, per
85
+ [`schema/events.md`](../../schema/events.md)). A resolver that raises is
86
+ logged once and costs you the identity on that event, nothing else: the
87
+ event is still recorded, and your handler's result or exception reaches the
88
+ client untouched.
89
+
90
+ ## Buffering and flush timing
91
+
92
+ Events are batched in memory rather than written one per call.
93
+ `buffer_size` (default 20) flushes after that many events;
94
+ `flush_interval_s` (default 5.0) flushes on a timer. Whichever comes first
95
+ wins, plus a best-effort `atexit` flush.
96
+
97
+ ```python
98
+ instrument(
99
+ server,
100
+ server_name="my-server",
101
+ sinks=[PostgresSink()],
102
+ buffer_size=100, # fewer, larger writes
103
+ flush_interval_s=10.0,
104
+ )
105
+ ```
106
+
107
+ Raising `buffer_size` trades memory and worst-case loss for fewer round
108
+ trips: a crash loses whatever is still buffered.
109
+
110
+ The `atexit` flush is best-effort only. It needs an event loop that may not
111
+ exist at interpreter shutdown, and it logs a warning and drops the buffer
112
+ when there is none. Anything that must not be lost should go through an
113
+ explicit `await handle_for(server).flush()` before you shut down.
114
+
115
+ `flush_interval_s=None` is manual mode, covered next.
116
+
59
117
  ## Request-scoped runtimes and manual flushing
60
118
 
61
119
  `instrument()` returns the server unchanged. `handle_for(server)` returns an
@@ -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.2.0"
11
+ version = "2.3.2"
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"
@@ -41,7 +41,7 @@ Issues = "https://github.com/zentered-studios/mcpsignals/issues"
41
41
  dev = [
42
42
  "pytest>=8.3",
43
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
44
+ "ruff>=0.15,<0.17", # single source of truth for the version CI uses
45
45
  ]
46
46
 
47
47
  [tool.hatch.build.targets.wheel]
@@ -1,4 +1,4 @@
1
- from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
1
+ from mcpsignals.events import ToolCallEvent
2
2
  from mcpsignals.handle import InstrumentHandle, handle_for
3
3
  from mcpsignals.instrument import instrument
4
4
  from mcpsignals.redaction import RedactionConfig
@@ -9,5 +9,4 @@ __all__ = [
9
9
  "InstrumentHandle",
10
10
  "RedactionConfig",
11
11
  "ToolCallEvent",
12
- "SessionSummaryEvent",
13
12
  ]
@@ -12,13 +12,11 @@ import atexit
12
12
  import logging
13
13
  from collections.abc import Sequence
14
14
 
15
- from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
15
+ from mcpsignals.events import Event
16
16
  from mcpsignals.sinks.base import Sink
17
17
 
18
18
  logger = logging.getLogger("mcpsignals")
19
19
 
20
- Event = ToolCallEvent | SessionSummaryEvent
21
-
22
20
 
23
21
  class EventBuffer:
24
22
  def __init__(
@@ -29,16 +29,7 @@ class ToolCallEvent:
29
29
  transport: str | None = None
30
30
 
31
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
32
+ #: What a sink receives. There is one event type today, so this is an alias
33
+ #: for `ToolCallEvent` rather than a union - it stays as the name sinks are
34
+ #: written against, and as the seam a second event type would widen.
35
+ Event = ToolCallEvent
@@ -18,10 +18,22 @@ calls where intent capture is off or the caller didn't pass one, even though
18
18
  the connection may well have a real session id.
19
19
  Track https://github.com/modelcontextprotocol/python-sdk for this being
20
20
  exposed to middleware in a future release.
21
+
22
+ Guarantee: nothing the library does around a tool call can change what the
23
+ client receives. The real handler always runs, its result is returned
24
+ unchanged, and its exception propagates unchanged. Every library-side step
25
+ (request byte counting, `resolve_identity`, redaction, event construction,
26
+ `buffer.add`) is guarded: a failure is logged once per `instrument()` call on
27
+ the `mcpsignals` logger, then suppressed, and the step falls back to a
28
+ neutral value (`request_bytes` 0, identity `(None, None)`, `arguments`
29
+ None). A failing redactor therefore records `arguments=None`, never the raw
30
+ arguments. A sink failure is handled separately by EventBuffer, also logged
31
+ once per sink.
21
32
  """
22
33
 
23
34
  import inspect
24
35
  import json
36
+ import logging
25
37
  import time
26
38
  from collections.abc import Awaitable, Callable, Mapping, Sequence
27
39
  from dataclasses import replace
@@ -33,11 +45,19 @@ from mcpsignals.error_kind import classify_error
33
45
  from mcpsignals.events import ToolCallEvent
34
46
  from mcpsignals.handle import InstrumentHandle
35
47
  from mcpsignals.handle import register as _register_handle
36
- from mcpsignals.intent_capture import enabled_for, inject_schema, strip_injected
48
+ from mcpsignals.intent_capture import (
49
+ MAX_IDENTIFIER_LENGTH,
50
+ bounded,
51
+ enabled_for,
52
+ inject_schema,
53
+ strip_injected,
54
+ )
37
55
  from mcpsignals.redaction import RedactionConfig, redact_arguments
38
56
  from mcpsignals.sinks.base import Sink
39
57
  from mcpsignals.sinks.console import ConsoleSink
40
58
 
59
+ logger = logging.getLogger("mcpsignals")
60
+
41
61
  ResolveIdentity = Callable[
42
62
  [Any], tuple[str | None, str | None] | Awaitable[tuple[str | None, str | None]]
43
63
  ]
@@ -106,6 +126,23 @@ def instrument(
106
126
  buffer = EventBuffer(active_sinks, buffer_size=buffer_size, flush_interval_s=flush_interval_s)
107
127
  _register_handle(server, InstrumentHandle(buffer))
108
128
 
129
+ # Same "log once, then suppress" pattern EventBuffer uses per sink, scoped
130
+ # to this instrument() call: one line is enough to surface a broken
131
+ # resolver or redactor, and a line per tool call would drown the host's logs.
132
+ warned = False
133
+
134
+ def _warn_once(step: str, exc: BaseException) -> None:
135
+ nonlocal warned
136
+ if warned:
137
+ return
138
+ warned = True
139
+ logger.error(
140
+ "mcpsignals: %s failed; the tool result is unaffected and further telemetry "
141
+ "errors from this instrument() call are suppressed: %r",
142
+ step,
143
+ exc,
144
+ )
145
+
109
146
  async def _mcpsignals_middleware(ctx, call_next):
110
147
  if ctx.method == "tools/list":
111
148
  result = await call_next(ctx)
@@ -131,13 +168,34 @@ def instrument(
131
168
  if ctx.method != "tools/call":
132
169
  return await call_next(ctx)
133
170
 
171
+ # `ts` is when the call started, not when it finished, and
172
+ # `duration_ms` is wall time from call start to response
173
+ # (schema/events.md): both are anchored here, before any library-side
174
+ # step, and `_record` reads them back after the handler settles.
175
+ #
176
+ # Not datetime.UTC: that alias is 3.11+, and requires-python allows
177
+ # 3.10. Ruff's UP017 would rewrite this, which is why ruff.toml pins
178
+ # target-version to py310.
179
+ ts = datetime.now(timezone.utc)
180
+ start = time.perf_counter()
181
+
134
182
  params = ctx.params or {}
135
- tool_name = params.get("name", "")
183
+ # `name` comes straight off the `tools/call` request, not from the
184
+ # registration: a client can send any string here (the call then
185
+ # fails, but the failed event still records it), so it takes the same
186
+ # identifier cap as the intent fields. The uncapped value still drives
187
+ # the intent-capture lookup and the forwarded call below.
188
+ raw_tool_name = params.get("name", "")
189
+ tool_name = bounded(raw_tool_name, MAX_IDENTIFIER_LENGTH) or ""
136
190
  raw_arguments = params.get("arguments") or {}
137
- request_bytes = len(json.dumps(raw_arguments, default=str).encode())
191
+ try:
192
+ request_bytes = len(json.dumps(raw_arguments, default=str).encode())
193
+ except Exception as exc: # noqa: BLE001 - an unmeasurable request never blocks the handler
194
+ _warn_once("request byte count", exc)
195
+ request_bytes = 0
138
196
 
139
197
  tool_intent_enabled = enabled_for(
140
- tool_name, global_enabled=intent_capture, overrides=intent_capture_tools
198
+ raw_tool_name, global_enabled=intent_capture, overrides=intent_capture_tools
141
199
  )
142
200
  if tool_intent_enabled:
143
201
  clean_arguments, extracted = strip_injected(raw_arguments)
@@ -149,36 +207,41 @@ def instrument(
149
207
  extracted = {"session_id": None, "agent_id": None, "intent": None}
150
208
  forward_ctx = ctx
151
209
 
152
- user_id: str | None = None
153
- org_id: str | None = None
154
- if resolve_identity is not None:
155
- identity = resolve_identity(ctx)
156
- if inspect.isawaitable(identity):
157
- identity = await identity
158
- if identity:
159
- user_id, org_id = identity
160
-
210
+ # The client declares its own name/version in the `initialize`
211
+ # handshake, so both are caller-controlled and take the same
212
+ # identifier cap as the intent fields.
161
213
  client_name: str | None = None
162
214
  client_version: str | None = None
163
215
  client_params = getattr(ctx.session, "client_params", None)
164
216
  client_info = getattr(client_params, "client_info", None) if client_params else None
165
217
  if client_info is not None:
166
- client_name = client_info.name
167
- client_version = client_info.version
218
+ client_name = bounded(client_info.name, MAX_IDENTIFIER_LENGTH)
219
+ client_version = bounded(client_info.version, MAX_IDENTIFIER_LENGTH)
168
220
 
169
221
  transport = "http" if getattr(ctx, "request", None) is not None else "stdio"
170
222
 
171
- start = time.perf_counter()
172
- error: BaseException | None = None
173
- result = None
174
- try:
175
- result = await call_next(forward_ctx)
176
- return result
177
- except BaseException as exc:
178
- error = exc
179
- raise
180
- finally:
223
+ async def _record(ts: datetime, error: BaseException | None, result: Any) -> None:
224
+ # First thing: the handler has just settled, so this is the
225
+ # response time. Everything below, `resolve_identity` included,
226
+ # is outside the timed window.
181
227
  duration_ms = int((time.perf_counter() - start) * 1000)
228
+
229
+ # Runs after the handler so its latency never lands in
230
+ # `duration_ms`. A host resolver that raises records a null
231
+ # identity; the handler's result is already on its way back.
232
+ user_id: str | None = None
233
+ org_id: str | None = None
234
+ if resolve_identity is not None:
235
+ try:
236
+ identity = resolve_identity(ctx)
237
+ if inspect.isawaitable(identity):
238
+ identity = await identity
239
+ if identity:
240
+ user_id, org_id = identity
241
+ except Exception as exc: # noqa: BLE001 - a broken resolver never blocks the handler
242
+ _warn_once("resolve_identity", exc)
243
+ user_id, org_id = None, None
244
+
182
245
  if error is not None:
183
246
  success = False
184
247
  error_message: str | None = str(error)[:2000]
@@ -190,13 +253,33 @@ def instrument(
190
253
  error_message = error_message[:2000]
191
254
  response_bytes = len(_serialize_for_bytes(result))
192
255
 
193
- arguments = redact_arguments(clean_arguments, redaction) if capture_arguments else None
256
+ # Guarded on its own so a broken redactor still leaves an event
257
+ # behind, recorded with `arguments=None` rather than the raw args.
258
+ #
259
+ # The `json.dumps` is not a formatting step, it is a proof that the
260
+ # value survives encoding. Every sink re-serializes `arguments` on
261
+ # its way out (`json.dumps` in postgres and bigquery,
262
+ # `dataclasses.asdict` then `json.dumps` in the console sink), and a
263
+ # value that cannot be encoded makes that whole `write()` raise.
264
+ # EventBuffer catches it, so the server is never affected, but the
265
+ # entire batch goes with it - including the unrelated events flushed
266
+ # alongside. Proving it here costs one event's `arguments` instead of
267
+ # a whole flush. Deliberately strict (no `default=`): it has to match
268
+ # the least forgiving sink, not the most.
269
+ #
270
+ # Only a `redaction.redactor` or a `redaction.allow` entry can
271
+ # produce such a value; the default type-only markers always encode.
272
+ arguments = None
273
+ if capture_arguments:
274
+ try:
275
+ arguments = redact_arguments(clean_arguments, redaction)
276
+ json.dumps(arguments)
277
+ except Exception as exc: # noqa: BLE001 - never fall back to the raw arguments
278
+ _warn_once("redaction", exc)
279
+ arguments = None
194
280
 
195
281
  event = ToolCallEvent(
196
- # Not datetime.UTC: that alias is 3.11+, and requires-python
197
- # allows 3.10. Ruff's UP017 would rewrite this, which is why
198
- # ruff.toml pins target-version to py310.
199
- ts=datetime.now(timezone.utc),
282
+ ts=ts,
200
283
  server_name=server_name,
201
284
  server_version=server_version,
202
285
  tool_name=tool_name,
@@ -218,5 +301,23 @@ def instrument(
218
301
  )
219
302
  await buffer.add(event)
220
303
 
304
+ error: BaseException | None = None
305
+ result = None
306
+ try:
307
+ result = await call_next(forward_ctx)
308
+ return result
309
+ except BaseException as exc:
310
+ error = exc
311
+ raise
312
+ finally:
313
+ # An exception raised inside `finally` replaces the handler's
314
+ # return value or exception, so the whole recording step is
315
+ # guarded here. `Exception` only: a cancellation raised while
316
+ # awaiting the resolver or the buffer must still propagate.
317
+ try:
318
+ await _record(ts, error, result)
319
+ except Exception as exc: # noqa: BLE001 - telemetry must never change the tool result
320
+ _warn_once("event recording", exc)
321
+
221
322
  server.middleware.append(_mcpsignals_middleware)
222
323
  return server
@@ -13,17 +13,20 @@ from typing import Any
13
13
 
14
14
  INJECTED_KEYS = ("session_id", "agent_id", "intent")
15
15
 
16
- # Length caps on the three values the calling agent supplies. Without them
17
- # these are the only unbounded caller-controlled strings on an event, and a
18
- # sink that batches writes in a transaction (Cloudflare D1's `batch()`, for
19
- # one) loses every unrelated event in the flush when one oversized value fails
20
- # its statement. Capping here rather than in each sink means every sink,
21
- # including third-party and future ones, inherits the bound.
16
+ # Length caps on the caller-controlled strings that land on an event: the
17
+ # three intent-capture values the calling agent supplies here, plus
18
+ # `tool_name` (read off the `tools/call` request) and `client_name`/
19
+ # `client_version` (declared in the `initialize` handshake), which
20
+ # `instrument.py` bounds with the same `bounded()`. Without them a sink that
21
+ # batches writes in a transaction (Cloudflare D1's `batch()`, for one) loses
22
+ # every unrelated event in the flush when one oversized value fails its
23
+ # statement. Capping before the event is built rather than in each sink means
24
+ # every sink, including third-party and future ones, inherits the bound.
22
25
  #
23
- # `intent` is prose and takes the same 2000 as `error_message`.
24
- # `session_id`/`agent_id` are identifiers - a UUID is 36 chars - so they take a
25
- # much tighter cap. Oversized values are truncated rather than dropped: losing
26
- # the tail of an id is cheaper than losing the event.
26
+ # `intent` is prose and takes the same 2000 as `error_message`. The rest are
27
+ # identifiers - a UUID is 36 chars - so they take a much tighter cap. Oversized
28
+ # values are truncated rather than dropped: losing the tail of an id is cheaper
29
+ # than losing the event.
27
30
  MAX_INTENT_LENGTH = 2000
28
31
  MAX_IDENTIFIER_LENGTH = 128
29
32
 
@@ -69,10 +72,14 @@ def inject_schema(input_schema: dict[str, Any] | None) -> dict[str, Any]:
69
72
  return schema
70
73
 
71
74
 
72
- def _bounded(value: Any, max_length: int) -> str | None:
73
- # A caller can send any JSON type for these keys. Anything that isn't a
74
- # string can't be length-bounded, so it's dropped rather than forwarded
75
- # into an event field typed `str | None`.
75
+ def bounded(value: Any, max_length: int) -> str | None:
76
+ """Return `value` cut to `max_length` characters, or None for a non-string.
77
+
78
+ A caller can send any JSON type for these keys. Anything that isn't a
79
+ string can't be length-bounded, so it's dropped rather than forwarded
80
+ into an event field typed `str | None`. Python slices by code point, so
81
+ the cut never lands inside a character.
82
+ """
76
83
  if not isinstance(value, str):
77
84
  return None
78
85
  return value[:max_length]
@@ -88,5 +95,5 @@ def strip_injected(
88
95
  the library.
89
96
  """
90
97
  args = dict(arguments) if arguments else {}
91
- extracted = {key: _bounded(args.pop(key, None), _MAX_LENGTHS[key]) for key in INJECTED_KEYS}
98
+ extracted = {key: bounded(args.pop(key, None), _MAX_LENGTHS[key]) for key in INJECTED_KEYS}
92
99
  return args, extracted
@@ -11,10 +11,18 @@ from typing import Any
11
11
 
12
12
 
13
13
  def _type_marker(value: Any) -> dict[str, str]:
14
+ # JSON type names, shared with the Node.js package (see redaction.ts) so a
15
+ # query on `__type` works the same over either package's events.
14
16
  if isinstance(value, bool):
15
- type_name = "bool"
17
+ type_name = "boolean"
18
+ elif isinstance(value, (int, float)):
19
+ type_name = "number"
20
+ elif isinstance(value, str):
21
+ type_name = "string"
16
22
  elif isinstance(value, list):
17
23
  type_name = "array"
24
+ elif isinstance(value, dict):
25
+ type_name = "object"
18
26
  elif value is None:
19
27
  type_name = "null"
20
28
  else:
@@ -1,6 +1,6 @@
1
1
  from typing import Protocol, runtime_checkable
2
2
 
3
- from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
3
+ from mcpsignals.events import ToolCallEvent
4
4
 
5
5
 
6
6
  @runtime_checkable
@@ -10,4 +10,4 @@ class Sink(Protocol):
10
10
  what EventBuffer already does upstream.
11
11
  """
12
12
 
13
- async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None: ...
13
+ async def write(self, events: list[ToolCallEvent]) -> None: ...
@@ -14,7 +14,7 @@ import asyncio
14
14
  import dataclasses
15
15
  import json
16
16
 
17
- from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
17
+ from mcpsignals.events import ToolCallEvent
18
18
 
19
19
 
20
20
  class BigQuerySink:
@@ -34,10 +34,7 @@ class BigQuerySink:
34
34
  row = dataclasses.asdict(event)
35
35
  row.pop("event_type", None)
36
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
- )
37
+ row["arguments"] = json.dumps(row["arguments"]) if row["arguments"] is not None else None
41
38
  return row
42
39
 
43
40
  def _insert_sync(self, table: str, rows: list[dict]) -> None:
@@ -47,11 +44,8 @@ class BigQuerySink:
47
44
  if errors:
48
45
  raise RuntimeError(f"bigquery insert errors for {table_ref}: {errors}")
49
46
 
50
- async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
47
+ async def write(self, events: list[ToolCallEvent]) -> None:
51
48
  tool_calls = [self._row(e) for e in events if isinstance(e, ToolCallEvent)]
52
- summaries = [self._row(e) for e in events if isinstance(e, SessionSummaryEvent)]
53
49
 
54
50
  if tool_calls:
55
51
  await asyncio.to_thread(self._insert_sync, "tool_call", tool_calls)
56
- if summaries:
57
- await asyncio.to_thread(self._insert_sync, "session_summary", summaries)
@@ -3,7 +3,7 @@ import json
3
3
  import sys
4
4
  from typing import TextIO
5
5
 
6
- from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
6
+ from mcpsignals.events import ToolCallEvent
7
7
 
8
8
 
9
9
  class ConsoleSink:
@@ -20,7 +20,7 @@ class ConsoleSink:
20
20
  def __init__(self, stream: TextIO | None = None) -> None:
21
21
  self._stream = stream
22
22
 
23
- async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
23
+ async def write(self, events: list[ToolCallEvent]) -> None:
24
24
  stream = self._stream if self._stream is not None else sys.stdout
25
25
  for event in events:
26
26
  payload = dataclasses.asdict(event)
@@ -14,7 +14,7 @@ Where our schema has no equivalent convention, we use a custom
14
14
 
15
15
  import json
16
16
 
17
- from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
17
+ from mcpsignals.events import ToolCallEvent
18
18
 
19
19
 
20
20
  class OtlpSink:
@@ -25,14 +25,11 @@ class OtlpSink:
25
25
  tracer = trace.get_tracer("mcpsignals")
26
26
  self._tracer = tracer
27
27
 
28
- async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
28
+ async def write(self, events: list[ToolCallEvent]) -> None:
29
29
  from opentelemetry.context import Context
30
30
  from opentelemetry.trace import SpanKind, Status, StatusCode
31
31
 
32
32
  for event in events:
33
- if not isinstance(event, ToolCallEvent):
34
- continue # session_summary has no OTel span mapping; warehouse sinks carry it
35
-
36
33
  attributes = {
37
34
  "gen_ai.operation.name": "execute_tool",
38
35
  "gen_ai.tool.name": event.tool_name,