mcpsignals 1.0.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
2
+ from mcpsignals.instrument import instrument
3
+ from mcpsignals.redaction import RedactionConfig
4
+
5
+ __all__ = [
6
+ "instrument",
7
+ "RedactionConfig",
8
+ "ToolCallEvent",
9
+ "SessionSummaryEvent",
10
+ ]
mcpsignals/buffer.py ADDED
@@ -0,0 +1,98 @@
1
+ """Batches events and flushes to every configured sink on a size threshold
2
+ or an interval, whichever comes first. A sink failure is caught, logged at
3
+ most once per sink instance, and never propagates - a broken sink must
4
+ never break the host MCP server.
5
+ """
6
+
7
+ import asyncio
8
+ import atexit
9
+ import logging
10
+ from collections.abc import Sequence
11
+
12
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
13
+ from mcpsignals.sinks.base import Sink
14
+
15
+ logger = logging.getLogger("mcpsignals")
16
+
17
+ Event = ToolCallEvent | SessionSummaryEvent
18
+
19
+
20
+ class EventBuffer:
21
+ def __init__(
22
+ self,
23
+ sinks: Sequence[Sink],
24
+ buffer_size: int = 20,
25
+ flush_interval_s: float = 5.0,
26
+ ):
27
+ self._sinks = list(sinks)
28
+ self._buffer_size = buffer_size
29
+ self._flush_interval_s = flush_interval_s
30
+ self._events: list[Event] = []
31
+ self._lock = asyncio.Lock()
32
+ self._warned_sinks: set[int] = set()
33
+ self._interval_task: asyncio.Task | None = None
34
+ atexit.register(self._atexit_flush)
35
+
36
+ def _ensure_interval_task(self) -> None:
37
+ # Started lazily on first event, from inside a running event loop -
38
+ # constructing EventBuffer itself must not require a loop to exist yet.
39
+ if self._interval_task is not None:
40
+ return
41
+ try:
42
+ loop = asyncio.get_running_loop()
43
+ except RuntimeError:
44
+ return
45
+ self._interval_task = loop.create_task(self._interval_loop())
46
+
47
+ async def _interval_loop(self) -> None:
48
+ while True:
49
+ await asyncio.sleep(self._flush_interval_s)
50
+ await self.flush()
51
+
52
+ async def add(self, event: Event) -> None:
53
+ self._ensure_interval_task()
54
+ async with self._lock:
55
+ self._events.append(event)
56
+ should_flush = len(self._events) >= self._buffer_size
57
+ if should_flush:
58
+ await self.flush()
59
+
60
+ async def flush(self) -> None:
61
+ async with self._lock:
62
+ if not self._events:
63
+ return
64
+ batch, self._events = self._events, []
65
+
66
+ results = await asyncio.gather(
67
+ *(self._write_to_sink(sink, batch) for sink in self._sinks),
68
+ return_exceptions=True,
69
+ )
70
+ for result in results:
71
+ if isinstance(result, Exception):
72
+ # _write_to_sink already logged; nothing else to do here.
73
+ pass
74
+
75
+ async def _write_to_sink(self, sink: Sink, batch: list[Event]) -> None:
76
+ try:
77
+ await sink.write(batch)
78
+ except Exception as exc: # noqa: BLE001 - a sink must never break the host server
79
+ sink_id = id(sink)
80
+ if sink_id not in self._warned_sinks:
81
+ self._warned_sinks.add(sink_id)
82
+ logger.error("mcpsignals: sink %r failed, dropping batch: %s", sink, exc)
83
+
84
+ def _atexit_flush(self) -> None:
85
+ if not self._events:
86
+ return
87
+ # Best-effort only: there is no guarantee a loop is available to
88
+ # await a real flush at interpreter shutdown. Callers that need a
89
+ # guaranteed flush should call `await buffer.flush()` explicitly
90
+ # before shutting down.
91
+ try:
92
+ asyncio.run(self.flush())
93
+ except RuntimeError:
94
+ logger.warning(
95
+ "mcpsignals: %d buffered event(s) dropped at exit (no event loop available "
96
+ "to flush) - call `await flush()` explicitly before shutdown for a guarantee",
97
+ len(self._events),
98
+ )
@@ -0,0 +1,26 @@
1
+ """Best-effort classification of a free-text error message into a coarse
2
+ bucket. This is NOT ground truth - `success` is. See schema/events.md for
3
+ the documented false-positive mode.
4
+ """
5
+
6
+ import re
7
+
8
+ _NOT_FOUND = re.compile(r"not found|does not exist|no such", re.IGNORECASE)
9
+ _EMPTY = re.compile(r"\bempty\b|no results?|nothing found|zero results", re.IGNORECASE)
10
+ _VALIDATION = re.compile(r"invalid|required|expected|must be|validation|schema", re.IGNORECASE)
11
+
12
+
13
+ def classify_error(message: str | None) -> str | None:
14
+ """Bucket an error message into not_found / empty / validation / internal.
15
+
16
+ Returns None when there is no message to classify (i.e. the call succeeded).
17
+ """
18
+ if not message:
19
+ return None
20
+ if _NOT_FOUND.search(message):
21
+ return "not_found"
22
+ if _EMPTY.search(message):
23
+ return "empty"
24
+ if _VALIDATION.search(message):
25
+ return "validation"
26
+ return "internal"
mcpsignals/events.py ADDED
@@ -0,0 +1,44 @@
1
+ """Event dataclasses. Field names match schema/events.md exactly."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Literal
6
+
7
+
8
+ @dataclass
9
+ class ToolCallEvent:
10
+ event_type: Literal["tool_call"] = field(init=False, default="tool_call")
11
+ ts: datetime = field(default=None) # type: ignore[assignment]
12
+ server_name: str = ""
13
+ server_version: str | None = None
14
+ tool_name: str = ""
15
+ session_id: str | None = None
16
+ agent_id: str | None = None
17
+ client_name: str | None = None
18
+ client_version: str | None = None
19
+ user_id: str | None = None
20
+ org_id: str | None = None
21
+ duration_ms: int = 0
22
+ success: bool = True
23
+ error_kind: str | None = None
24
+ error_message: str | None = None
25
+ request_bytes: int = 0
26
+ response_bytes: int = 0
27
+ arguments: dict[str, Any] | None = None
28
+ intent: str | None = None
29
+ transport: str | None = None
30
+
31
+
32
+ @dataclass
33
+ class SessionSummaryEvent:
34
+ event_type: Literal["session_summary"] = field(init=False, default="session_summary")
35
+ ts: datetime = field(default=None) # type: ignore[assignment]
36
+ session_id: str = ""
37
+ server_name: str = ""
38
+ server_version: str | None = None
39
+ user_id: str | None = None
40
+ org_id: str | None = None
41
+ call_count: int = 0
42
+ distinct_tools_used: int = 0
43
+ wall_duration_ms: int = 0
44
+ error_count: int = 0
@@ -0,0 +1,212 @@
1
+ """Core instrumentation entry point.
2
+
3
+ Built on the `mcp` v2 SDK's `middleware` list (`server.middleware.append(fn)`),
4
+ present on both `MCPServer` and the low-level `Server` - the same mechanism
5
+ works for both, no separate code paths needed. See
6
+ https://py.sdk.modelcontextprotocol.io/v2/advanced/middleware/.
7
+
8
+ Known SDK limitation (verified against the installed mcp==2.0.0 source,
9
+ mcp/server/context.py): `ServerRequestContext` - what middleware receives -
10
+ does not publicly expose the transport's connection-level session id or a
11
+ `connection` accessor (only the handler-facing `Context` class does, via a
12
+ private `Connection` it doesn't share with middleware). We do not reach into
13
+ that private attribute. As a result `session_id` on emitted events is only
14
+ ever populated from the optional intent-capture value the calling agent
15
+ supplies - it is `None` for calls where intent capture is off or the caller
16
+ didn't pass one, even though the connection may well have a real session id.
17
+ Track https://github.com/modelcontextprotocol/python-sdk for this being
18
+ exposed to middleware in a future release.
19
+ """
20
+
21
+ import inspect
22
+ import json
23
+ import time
24
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
25
+ from dataclasses import replace
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+ from mcpsignals.buffer import EventBuffer
30
+ from mcpsignals.error_kind import classify_error
31
+ from mcpsignals.events import ToolCallEvent
32
+ from mcpsignals.intent_capture import enabled_for, inject_schema, strip_injected
33
+ from mcpsignals.redaction import RedactionConfig, redact_arguments
34
+ from mcpsignals.sinks.base import Sink
35
+ from mcpsignals.sinks.console import ConsoleSink
36
+
37
+ ResolveIdentity = Callable[
38
+ [Any], tuple[str | None, str | None] | Awaitable[tuple[str | None, str | None]]
39
+ ]
40
+
41
+
42
+ def _is_error_result(result: Any) -> bool:
43
+ # `call_next` for `tools/call` returns a plain dict (wire-shaped, camelCase
44
+ # keys) in mcp==2.0.0, not a CallToolResult instance - verified against the
45
+ # installed package. Handle both shapes so this survives either.
46
+ if isinstance(result, Mapping):
47
+ return bool(result.get("isError", result.get("is_error", False)))
48
+ return bool(getattr(result, "is_error", False))
49
+
50
+
51
+ def _result_content(result: Any) -> Any:
52
+ if isinstance(result, Mapping):
53
+ return result.get("content")
54
+ return getattr(result, "content", None)
55
+
56
+
57
+ def _content_to_text(content: Any) -> str | None:
58
+ if not content:
59
+ return None
60
+ parts = []
61
+ for block in content:
62
+ text = block.get("text") if isinstance(block, Mapping) else getattr(block, "text", None)
63
+ if text:
64
+ parts.append(text)
65
+ return "\n".join(parts) if parts else None
66
+
67
+
68
+ def _serialize_for_bytes(value: Any) -> bytes:
69
+ if value is None:
70
+ return b""
71
+ dump = getattr(value, "model_dump_json", None)
72
+ if callable(dump):
73
+ return dump().encode()
74
+ return json.dumps(value, default=str).encode()
75
+
76
+
77
+ def instrument(
78
+ server: Any,
79
+ *,
80
+ server_name: str,
81
+ server_version: str | None = None,
82
+ sinks: Sequence[Sink] | None = None,
83
+ capture_arguments: bool = False,
84
+ redaction: RedactionConfig | None = None,
85
+ intent_capture: bool = False,
86
+ intent_capture_tools: Mapping[str, bool] | None = None,
87
+ resolve_identity: ResolveIdentity | None = None,
88
+ buffer_size: int = 20,
89
+ flush_interval_s: float = 5.0,
90
+ ) -> Any:
91
+ """Wrap `server` (an `MCPServer` or a low-level `Server`) so every tool
92
+ call is recorded as a `tool_call` event, per schema/events.md. Appends
93
+ one middleware function; call this once, right after constructing your
94
+ server. Returns the same instance, unmodified otherwise.
95
+ """
96
+ active_sinks: list[Sink] = list(sinks) if sinks else [ConsoleSink()]
97
+ buffer = EventBuffer(active_sinks, buffer_size=buffer_size, flush_interval_s=flush_interval_s)
98
+
99
+ async def _mcpsignals_middleware(ctx, call_next):
100
+ if ctx.method == "tools/list":
101
+ result = await call_next(ctx)
102
+ if intent_capture or intent_capture_tools:
103
+ # `call_next` returns a wire-shaped dict here (camelCase keys),
104
+ # not a ListToolsResult/Tool instance - verified against the
105
+ # installed mcp==2.0.0 package. Handle both shapes defensively.
106
+ if isinstance(result, Mapping):
107
+ for tool in result.get("tools") or []:
108
+ name = tool.get("name")
109
+ if enabled_for(
110
+ name, global_enabled=intent_capture, overrides=intent_capture_tools
111
+ ):
112
+ tool["inputSchema"] = inject_schema(tool.get("inputSchema"))
113
+ else:
114
+ for tool in getattr(result, "tools", None) or []:
115
+ if enabled_for(
116
+ tool.name, global_enabled=intent_capture, overrides=intent_capture_tools
117
+ ):
118
+ tool.input_schema = inject_schema(tool.input_schema)
119
+ return result
120
+
121
+ if ctx.method != "tools/call":
122
+ return await call_next(ctx)
123
+
124
+ params = ctx.params or {}
125
+ tool_name = params.get("name", "")
126
+ raw_arguments = params.get("arguments") or {}
127
+ request_bytes = len(json.dumps(raw_arguments, default=str).encode())
128
+
129
+ tool_intent_enabled = enabled_for(
130
+ tool_name, global_enabled=intent_capture, overrides=intent_capture_tools
131
+ )
132
+ if tool_intent_enabled:
133
+ clean_arguments, extracted = strip_injected(raw_arguments)
134
+ forwarded_params = dict(params)
135
+ forwarded_params["arguments"] = clean_arguments
136
+ forward_ctx = replace(ctx, params=forwarded_params)
137
+ else:
138
+ clean_arguments = raw_arguments
139
+ extracted = {"session_id": None, "agent_id": None, "intent": None}
140
+ forward_ctx = ctx
141
+
142
+ user_id: str | None = None
143
+ org_id: str | None = None
144
+ if resolve_identity is not None:
145
+ identity = resolve_identity(ctx)
146
+ if inspect.isawaitable(identity):
147
+ identity = await identity
148
+ if identity:
149
+ user_id, org_id = identity
150
+
151
+ client_name: str | None = None
152
+ client_version: str | None = None
153
+ client_params = getattr(ctx.session, "client_params", None)
154
+ client_info = getattr(client_params, "client_info", None) if client_params else None
155
+ if client_info is not None:
156
+ client_name = client_info.name
157
+ client_version = client_info.version
158
+
159
+ transport = "http" if getattr(ctx, "request", None) is not None else "stdio"
160
+
161
+ start = time.perf_counter()
162
+ error: BaseException | None = None
163
+ result = None
164
+ try:
165
+ result = await call_next(forward_ctx)
166
+ return result
167
+ except BaseException as exc:
168
+ error = exc
169
+ raise
170
+ finally:
171
+ duration_ms = int((time.perf_counter() - start) * 1000)
172
+ if error is not None:
173
+ success = False
174
+ error_message: str | None = str(error)[:2000]
175
+ response_bytes = 0
176
+ else:
177
+ success = not _is_error_result(result)
178
+ error_message = None if success else _content_to_text(_result_content(result))
179
+ if error_message:
180
+ error_message = error_message[:2000]
181
+ response_bytes = len(_serialize_for_bytes(result))
182
+
183
+ arguments = redact_arguments(clean_arguments, redaction) if capture_arguments else None
184
+
185
+ event = ToolCallEvent(
186
+ # Not datetime.UTC: that alias is 3.11+, and requires-python
187
+ # allows 3.10. Ruff's UP017 would rewrite this, which is why
188
+ # ruff.toml pins target-version to py310.
189
+ ts=datetime.now(timezone.utc),
190
+ server_name=server_name,
191
+ server_version=server_version,
192
+ tool_name=tool_name,
193
+ session_id=extracted.get("session_id"),
194
+ agent_id=extracted.get("agent_id"),
195
+ client_name=client_name,
196
+ client_version=client_version,
197
+ user_id=user_id,
198
+ org_id=org_id,
199
+ duration_ms=duration_ms,
200
+ success=success,
201
+ error_kind=classify_error(error_message),
202
+ error_message=error_message,
203
+ request_bytes=request_bytes,
204
+ response_bytes=response_bytes,
205
+ arguments=arguments,
206
+ intent=extracted.get("intent"),
207
+ transport=transport,
208
+ )
209
+ await buffer.add(event)
210
+
211
+ server.middleware.append(_mcpsignals_middleware)
212
+ return server
@@ -0,0 +1,62 @@
1
+ """Intent capture: optionally injects session_id/agent_id/intent into the
2
+ JSON schema a tool advertises, and strips those same keys back out of the
3
+ arguments before the user's tool handler runs, so the handler receives
4
+ exactly the arguments it would have received without the library.
5
+
6
+ Off by default. See prompt spec / README for the documented tradeoff: this
7
+ adds tokens to every advertised tool schema and asks the calling model to do
8
+ extra work it may ignore or fabricate.
9
+ """
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+ INJECTED_KEYS = ("session_id", "agent_id", "intent")
15
+
16
+ _INJECTED_PROPERTIES = {
17
+ "session_id": {
18
+ "type": "string",
19
+ "description": "Opaque id grouping this call with other calls in the same task, if known.",
20
+ },
21
+ "agent_id": {
22
+ "type": "string",
23
+ "description": "Opaque id identifying which agent is making this call, if multiple agents share a session.",
24
+ },
25
+ "intent": {
26
+ "type": "string",
27
+ "description": "Why you are calling this tool.",
28
+ },
29
+ }
30
+
31
+
32
+ def enabled_for(
33
+ tool_name: str, *, global_enabled: bool, overrides: Mapping[str, bool] | None
34
+ ) -> bool:
35
+ if overrides is not None and tool_name in overrides:
36
+ return overrides[tool_name]
37
+ return global_enabled
38
+
39
+
40
+ def inject_schema(input_schema: dict[str, Any] | None) -> dict[str, Any]:
41
+ """Return a new JSON schema with the injected properties added. Never
42
+ mutates the input schema in place.
43
+ """
44
+ schema = dict(input_schema) if input_schema else {"type": "object"}
45
+ properties = dict(schema.get("properties") or {})
46
+ for key, spec in _INJECTED_PROPERTIES.items():
47
+ properties.setdefault(key, spec)
48
+ schema["properties"] = properties
49
+ return schema
50
+
51
+
52
+ def strip_injected(
53
+ arguments: Mapping[str, Any] | None,
54
+ ) -> tuple[dict[str, Any], dict[str, str | None]]:
55
+ """Split raw arguments into (clean_arguments, extracted) where
56
+ `extracted` holds session_id/agent_id/intent (None if absent) and
57
+ `clean_arguments` has those three keys removed - exactly what the
58
+ handler would have received without the library.
59
+ """
60
+ args = dict(arguments) if arguments else {}
61
+ extracted = {key: args.pop(key, None) for key in INJECTED_KEYS}
62
+ return args, extracted
@@ -0,0 +1,52 @@
1
+ """Argument redaction. Only ever invoked when argument capture is explicitly
2
+ enabled. Default behavior (capture on, no config): record keys and value
3
+ types only, never values. See the redaction section of the root README
4
+ before changing this file - it is the single most safety-critical part of
5
+ the library.
6
+ """
7
+
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+
13
+ def _type_marker(value: Any) -> dict[str, str]:
14
+ if isinstance(value, bool):
15
+ type_name = "bool"
16
+ elif isinstance(value, list):
17
+ type_name = "array"
18
+ elif value is None:
19
+ type_name = "null"
20
+ else:
21
+ type_name = type(value).__name__
22
+ return {"__type": type_name}
23
+
24
+
25
+ @dataclass
26
+ class RedactionConfig:
27
+ allow: list[str] | None = None
28
+ deny: list[str] | None = None
29
+ redactor: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None
30
+
31
+
32
+ def redact_arguments(args: Mapping[str, Any], config: RedactionConfig | None) -> dict[str, Any]:
33
+ if config is not None and config.redactor is not None:
34
+ return dict(config.redactor(args))
35
+
36
+ # Fail-safe by design: only `allow` ever reveals a real value. A bare
37
+ # `deny` with no `allow` does NOT imply "reveal everything else" - it
38
+ # would be a footgun if denying one sensitive key silently leaked every
39
+ # other field the caller didn't think to deny.
40
+ allow = set(config.allow) if config is not None and config.allow else None
41
+ deny = set(config.deny) if config is not None and config.deny else set()
42
+
43
+ result: dict[str, Any] = {}
44
+ for key, value in args.items():
45
+ if key in deny:
46
+ result[key] = _type_marker(value)
47
+ elif allow is not None:
48
+ result[key] = value if key in allow else _type_marker(value)
49
+ else:
50
+ # No allow/deny configured at all: keys and value types only, never values.
51
+ result[key] = _type_marker(value)
52
+ return result
@@ -0,0 +1,25 @@
1
+ from mcpsignals.sinks.base import Sink
2
+ from mcpsignals.sinks.console import ConsoleSink
3
+
4
+ __all__ = ["Sink", "ConsoleSink"]
5
+
6
+ try:
7
+ from mcpsignals.sinks.postgres import PostgresSink # noqa: F401
8
+
9
+ __all__.append("PostgresSink")
10
+ except ImportError:
11
+ pass
12
+
13
+ try:
14
+ from mcpsignals.sinks.bigquery import BigQuerySink # noqa: F401
15
+
16
+ __all__.append("BigQuerySink")
17
+ except ImportError:
18
+ pass
19
+
20
+ try:
21
+ from mcpsignals.sinks.otlp import OtlpSink # noqa: F401
22
+
23
+ __all__.append("OtlpSink")
24
+ except ImportError:
25
+ pass
@@ -0,0 +1,13 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
4
+
5
+
6
+ @runtime_checkable
7
+ class Sink(Protocol):
8
+ """A sink accepts a batch of events, writes them, and reports failure by
9
+ raising. It does nothing else - no retries, no local queueing beyond
10
+ what EventBuffer already does upstream.
11
+ """
12
+
13
+ async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None: ...
@@ -0,0 +1,47 @@
1
+ """BigQuery sink. Requires the `bigquery` extra (google-cloud-bigquery).
2
+ Credentials come from Application Default Credentials, same as the client
3
+ library's own defaults - never an invented config file. The client library
4
+ is synchronous, so writes run in a thread via asyncio.to_thread and never
5
+ block the event loop.
6
+ """
7
+
8
+ import asyncio
9
+ import dataclasses
10
+
11
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
12
+
13
+
14
+ class BigQuerySink:
15
+ def __init__(self, dataset: str, project: str | None = None, client=None):
16
+ self._dataset = dataset
17
+ self._project = project
18
+ self._client = client
19
+
20
+ def _get_client(self):
21
+ if self._client is None:
22
+ from google.cloud import bigquery # local import: don't require the SDK unless used
23
+
24
+ self._client = bigquery.Client(project=self._project)
25
+ return self._client
26
+
27
+ def _row(self, event) -> dict:
28
+ row = dataclasses.asdict(event)
29
+ row.pop("event_type", None)
30
+ row["ts"] = event.ts.isoformat() if event.ts else None
31
+ return row
32
+
33
+ def _insert_sync(self, table: str, rows: list[dict]) -> None:
34
+ client = self._get_client()
35
+ table_ref = f"{self._dataset}.{table}"
36
+ errors = client.insert_rows_json(table_ref, rows)
37
+ if errors:
38
+ raise RuntimeError(f"bigquery insert errors for {table_ref}: {errors}")
39
+
40
+ async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
41
+ tool_calls = [self._row(e) for e in events if isinstance(e, ToolCallEvent)]
42
+ summaries = [self._row(e) for e in events if isinstance(e, SessionSummaryEvent)]
43
+
44
+ if tool_calls:
45
+ await asyncio.to_thread(self._insert_sync, "tool_call", tool_calls)
46
+ if summaries:
47
+ await asyncio.to_thread(self._insert_sync, "session_summary", summaries)
@@ -0,0 +1,17 @@
1
+ import dataclasses
2
+ import json
3
+ import sys
4
+
5
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
6
+
7
+
8
+ class ConsoleSink:
9
+ """The default sink. Zero configuration, writes one JSON line per event
10
+ to stdout so the library is useful without setting up a warehouse.
11
+ """
12
+
13
+ async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
14
+ for event in events:
15
+ payload = dataclasses.asdict(event)
16
+ payload["ts"] = event.ts.isoformat() if event.ts else None
17
+ print(json.dumps(payload, default=str), file=sys.stdout, flush=True)
@@ -0,0 +1,86 @@
1
+ """OTLP sink. Requires the `otlp` extra (opentelemetry-api only, not an SDK
2
+ or exporter - relies on whatever global TracerProvider the host app already
3
+ configured, the standard "zero-code" OTel pattern).
4
+
5
+ Field mapping verified against the live OpenTelemetry GenAI semantic
6
+ conventions for MCP (open-telemetry/semantic-conventions-genai,
7
+ docs/gen-ai/mcp.md) as of this writing. Everything in that document is
8
+ Development/Experimental status, not Stable, except where noted below.
9
+ Where our schema has no equivalent convention, we use a custom
10
+ `mcpsignals.*` attribute rather than force a bad fit (see `transport`).
11
+ """
12
+
13
+ import json
14
+
15
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
16
+
17
+
18
+ class OtlpSink:
19
+ def __init__(self, tracer=None):
20
+ if tracer is None:
21
+ from opentelemetry import trace # local import: don't require the API unless used
22
+
23
+ tracer = trace.get_tracer("mcpsignals")
24
+ self._tracer = tracer
25
+
26
+ async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
27
+ from opentelemetry.trace import SpanKind, Status, StatusCode
28
+
29
+ for event in events:
30
+ if not isinstance(event, ToolCallEvent):
31
+ continue # session_summary has no OTel span mapping; warehouse sinks carry it
32
+
33
+ attributes = {
34
+ "gen_ai.operation.name": "execute_tool",
35
+ "gen_ai.tool.name": event.tool_name,
36
+ "mcpsignals.server.name": event.server_name,
37
+ "mcpsignals.request.bytes": event.request_bytes,
38
+ "mcpsignals.response.bytes": event.response_bytes,
39
+ }
40
+ if event.session_id:
41
+ attributes["mcp.session.id"] = event.session_id
42
+ if event.server_version:
43
+ attributes["mcpsignals.server.version"] = event.server_version
44
+ if event.client_name:
45
+ attributes["mcpsignals.client.name"] = event.client_name
46
+ if event.client_version:
47
+ attributes["mcpsignals.client.version"] = event.client_version
48
+ if event.agent_id:
49
+ attributes["mcpsignals.agent.id"] = event.agent_id
50
+ if event.user_id:
51
+ # enduser.id is Stable in general OTel semconv, but is
52
+ # security-sensitive/opt-in - only set when the host supplied one.
53
+ attributes["enduser.id"] = event.user_id
54
+ if event.org_id:
55
+ attributes["mcpsignals.org.id"] = event.org_id
56
+ if event.transport:
57
+ # Deliberately NOT network.transport: that attribute's vocabulary
58
+ # (tcp/udp/quic/pipe/unix) does not fit MCP's stdio/http.
59
+ attributes["mcpsignals.transport"] = event.transport
60
+ if event.intent:
61
+ attributes["mcpsignals.intent"] = event.intent
62
+ if event.error_kind:
63
+ attributes["mcpsignals.error.kind"] = event.error_kind
64
+ if event.arguments is not None:
65
+ attributes["gen_ai.tool.call.arguments"] = json.dumps(event.arguments)
66
+
67
+ # Events are written in batches, potentially long after the call
68
+ # happened, so the span's start/end must be stamped from the
69
+ # event's own ts/duration_ms rather than "now" - otherwise every
70
+ # span would report the buffer's flush time as its duration.
71
+ start_ns = int(event.ts.timestamp() * 1_000_000_000) if event.ts else None
72
+ end_ns = start_ns + event.duration_ms * 1_000_000 if start_ns is not None else None
73
+
74
+ span = self._tracer.start_span(
75
+ f"tools/call {event.tool_name}",
76
+ kind=SpanKind.SERVER,
77
+ attributes=attributes,
78
+ start_time=start_ns,
79
+ )
80
+ if event.success:
81
+ span.set_status(Status(StatusCode.OK))
82
+ else:
83
+ span.set_status(Status(StatusCode.ERROR, description=event.error_message))
84
+ if event.error_message:
85
+ span.record_exception(Exception(event.error_message))
86
+ span.end(end_time=end_ns)
@@ -0,0 +1,115 @@
1
+ """Postgres sink. Requires the `postgres` extra (asyncpg). Table DDL lives in
2
+ schema/events.md. Connection info comes from asyncpg's own env var defaults
3
+ (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE) or an explicit dsn/pool -
4
+ never an invented config file.
5
+ """
6
+
7
+ from mcpsignals.events import SessionSummaryEvent, ToolCallEvent
8
+
9
+ _TOOL_CALL_COLUMNS = [
10
+ "ts",
11
+ "server_name",
12
+ "server_version",
13
+ "tool_name",
14
+ "session_id",
15
+ "agent_id",
16
+ "client_name",
17
+ "client_version",
18
+ "user_id",
19
+ "org_id",
20
+ "duration_ms",
21
+ "success",
22
+ "error_kind",
23
+ "error_message",
24
+ "request_bytes",
25
+ "response_bytes",
26
+ "arguments",
27
+ "intent",
28
+ "transport",
29
+ ]
30
+
31
+ _SESSION_SUMMARY_COLUMNS = [
32
+ "ts",
33
+ "session_id",
34
+ "server_name",
35
+ "server_version",
36
+ "user_id",
37
+ "org_id",
38
+ "call_count",
39
+ "distinct_tools_used",
40
+ "wall_duration_ms",
41
+ "error_count",
42
+ ]
43
+
44
+
45
+ class PostgresSink:
46
+ def __init__(self, dsn: str | None = None, pool=None):
47
+ """Provide either `dsn` (asyncpg will connect lazily) or an existing
48
+ `pool` (an asyncpg.Pool you already manage). If neither is given,
49
+ asyncpg's own environment-variable defaults apply.
50
+ """
51
+ self._dsn = dsn
52
+ self._pool = pool
53
+
54
+ async def _get_pool(self):
55
+ import asyncpg # local import: don't require asyncpg unless this sink is used
56
+
57
+ if self._pool is None:
58
+ self._pool = await asyncpg.create_pool(dsn=self._dsn)
59
+ return self._pool
60
+
61
+ async def write(self, events: list[ToolCallEvent | SessionSummaryEvent]) -> None:
62
+ import json as _json
63
+
64
+ tool_calls = [e for e in events if isinstance(e, ToolCallEvent)]
65
+ summaries = [e for e in events if isinstance(e, SessionSummaryEvent)]
66
+
67
+ pool = await self._get_pool()
68
+ async with pool.acquire() as conn:
69
+ if tool_calls:
70
+ rows = [
71
+ (
72
+ e.ts,
73
+ e.server_name,
74
+ e.server_version,
75
+ e.tool_name,
76
+ e.session_id,
77
+ e.agent_id,
78
+ e.client_name,
79
+ e.client_version,
80
+ e.user_id,
81
+ e.org_id,
82
+ e.duration_ms,
83
+ e.success,
84
+ e.error_kind,
85
+ e.error_message,
86
+ e.request_bytes,
87
+ e.response_bytes,
88
+ _json.dumps(e.arguments) if e.arguments is not None else None,
89
+ e.intent,
90
+ e.transport,
91
+ )
92
+ for e in tool_calls
93
+ ]
94
+ await conn.copy_records_to_table(
95
+ "mcpsignals_tool_call", records=rows, columns=_TOOL_CALL_COLUMNS
96
+ )
97
+ if summaries:
98
+ rows = [
99
+ (
100
+ e.ts,
101
+ e.session_id,
102
+ e.server_name,
103
+ e.server_version,
104
+ e.user_id,
105
+ e.org_id,
106
+ e.call_count,
107
+ e.distinct_tools_used,
108
+ e.wall_duration_ms,
109
+ e.error_count,
110
+ )
111
+ for e in summaries
112
+ ]
113
+ await conn.copy_records_to_table(
114
+ "mcpsignals_session_summary", records=rows, columns=_SESSION_SUMMARY_COLUMNS
115
+ )
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcpsignals
3
+ Version: 1.0.1
4
+ Summary: Drop-in instrumentation for MCP servers. Writes tool-call events to a database you own.
5
+ Project-URL: Homepage, https://github.com/zentered-studios/mcpsignals#readme
6
+ Project-URL: Repository, https://github.com/zentered-studios/mcpsignals
7
+ Project-URL: Issues, https://github.com/zentered-studios/mcpsignals/issues
8
+ Author: mcpsignals contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: mcp>=2.0.0
19
+ Provides-Extra: bigquery
20
+ Requires-Dist: google-cloud-bigquery>=3.11; extra == 'bigquery'
21
+ Provides-Extra: otlp
22
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otlp'
23
+ Provides-Extra: postgres
24
+ Requires-Dist: asyncpg>=0.29; extra == 'postgres'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # mcpsignals (Python)
28
+
29
+ Drop-in instrumentation for MCP servers. Wrap your server, point it at a
30
+ sink, and it records what agents did with your tools into a database you
31
+ own - no hosted service, no account.
32
+
33
+ Requires **Python 3.10 or newer**. Targets the v2 `mcp` SDK (`MCPServer` /
34
+ low-level `Server`, `mcp>=2.0.0`).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install mcpsignals
40
+ # with a warehouse sink:
41
+ pip install "mcpsignals[postgres]" # or [bigquery], [otlp]
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from mcp.server.mcpserver import MCPServer
48
+ from mcpsignals import instrument
49
+
50
+ server = MCPServer("my-server")
51
+ instrument(server, server_name="my-server", server_version="1.0.0")
52
+
53
+
54
+ @server.tool()
55
+ def search(query: str) -> str:
56
+ """Search something."""
57
+ return f"results for {query}"
58
+ ```
59
+
60
+ Call `instrument()` any time before the server starts handling requests. It
61
+ appends to `server.middleware`, and that chain is rebuilt from the live list
62
+ on every request, so calling it before or after your `@server.tool()`
63
+ definitions makes no difference. The Node.js package differs here: it wraps
64
+ `registerTool`, so it has to run before any tool is registered.
65
+
66
+ With no sink configured it writes JSON lines to stdout. To write to your
67
+ own warehouse instead:
68
+
69
+ ```python
70
+ from mcpsignals import instrument
71
+ from mcpsignals.sinks import PostgresSink
72
+
73
+ instrument(server, server_name="my-server", sinks=[PostgresSink(dsn="postgresql://...")])
74
+ ```
75
+
76
+ ## Argument capture and redaction
77
+
78
+ Off by default: no tool arguments are recorded unless you opt in with
79
+ `capture_arguments=True`. Even then, by default only argument **keys and
80
+ value types** are recorded, never values. See the root README's redaction
81
+ section before enabling this in anything handling real user data.
82
+
83
+ ## Known limitation: `session_id`
84
+
85
+ `mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
86
+ expose the transport's connection-level session id (only the
87
+ handler-facing `Context` class does, via a private accessor middleware
88
+ doesn't have). `session_id` on emitted events is therefore only ever
89
+ populated when intent capture is enabled and the calling agent supplies
90
+ one - not from the underlying transport connection. See `instrument.py`
91
+ for details; this will be revisited if/when upstream exposes it to
92
+ middleware.
93
+
94
+ ## Full docs
95
+
96
+ See the [root README](../../README.md) for the redaction model, the sink
97
+ comparison, and intent capture, and [`schema/events.md`](../../schema/events.md)
98
+ for the event field reference.
@@ -0,0 +1,17 @@
1
+ mcpsignals/__init__.py,sha256=UUlteWZUvribqzzyjqN-o_t_wwSxuJvCZyOqjnLLUhY,263
2
+ mcpsignals/buffer.py,sha256=pR-jXkSIgivK43LSo6utoBRDLuGdeFQvVfKzjrwvm_w,3533
3
+ mcpsignals/error_kind.py,sha256=hdDDIcR1qdHnvByNbsgVsLRJ-N-6UwDmiEGQyqb3iU4,927
4
+ mcpsignals/events.py,sha256=goRlcGMUjF2kap6RCEnpqT5ULE-7wh6KV41F2c6ZF6A,1382
5
+ mcpsignals/instrument.py,sha256=qyUEj1rct1PdhfRuZLIZJWWlYVlE5wBG3mMMfJS14ts,8847
6
+ mcpsignals/intent_capture.py,sha256=FQSGW_cQoz_Zic10Ctndm0fCvGAK4Zjl1mULTFuQPY0,2277
7
+ mcpsignals/redaction.py,sha256=NZD__B3zz6Mz1JldA5GyqYQvWUohVgZ3ERLJ_1borJ8,1947
8
+ mcpsignals/sinks/__init__.py,sha256=neQVvVCFZWKytGMf3uzwAXmLxsXksx7WQLf4iMA_Q2I,531
9
+ mcpsignals/sinks/base.py,sha256=7JjZNe0aoi4v6cbMDyIt_iAHVRBvDTsdIjHBwMSTLa0,449
10
+ mcpsignals/sinks/bigquery.py,sha256=E6GaL7d0M1xOOZFa01dVRqilHvW9TYXo2tA_eoAFeQY,1847
11
+ mcpsignals/sinks/console.py,sha256=926pA2B9CT_j7qAqVhXbikesjRhqknf5Do5biD9hXYY,600
12
+ mcpsignals/sinks/otlp.py,sha256=mVr5Y3DWK_aML7R1RO1DQcrCq2u49nEvILvxqoFTwLs,4140
13
+ mcpsignals/sinks/postgres.py,sha256=a5b_1SArzNkWrTFeVv_j-4qFhzYsGYOblItseAusoI0,3632
14
+ mcpsignals-1.0.1.dist-info/METADATA,sha256=FvnK02msnXcdJFljEI9oK0mN5Zon08AbLGKSOmaP-Ck,3547
15
+ mcpsignals-1.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
16
+ mcpsignals-1.0.1.dist-info/licenses/LICENSE,sha256=l_eNViACILOiQuQ8PWYLhzbtpxPj7-SggB-HmydLiuQ,1080
17
+ mcpsignals-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcpsignals contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.