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.
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/PKG-INFO +59 -1
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/README.md +58 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/pyproject.toml +2 -2
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/__init__.py +1 -2
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/buffer.py +1 -3
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/events.py +4 -13
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/instrument.py +131 -30
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/intent_capture.py +22 -15
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/redaction.py +9 -1
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/base.py +2 -2
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/bigquery.py +3 -9
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/console.py +2 -2
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/otlp.py +2 -5
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/postgres.py +10 -37
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_bigquery.py +4 -23
- mcpsignals-2.3.2/tests/test_instrument.py +718 -0
- mcpsignals-2.3.2/tests/test_otlp.py +206 -0
- mcpsignals-2.3.2/tests/test_postgres.py +155 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_redaction.py +6 -6
- mcpsignals-2.3.2/uv.lock +1649 -0
- mcpsignals-2.2.0/tests/test_instrument.py +0 -360
- mcpsignals-2.2.0/tests/test_otlp.py +0 -85
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/.gitignore +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/LICENSE +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/error_kind.py +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/handle.py +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/src/mcpsignals/sinks/__init__.py +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_buffer.py +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_console.py +0 -0
- {mcpsignals-2.2.0 → mcpsignals-2.3.2}/tests/test_error_kind.py +0 -0
- {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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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:
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|