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 +10 -0
- mcpsignals/buffer.py +98 -0
- mcpsignals/error_kind.py +26 -0
- mcpsignals/events.py +44 -0
- mcpsignals/instrument.py +212 -0
- mcpsignals/intent_capture.py +62 -0
- mcpsignals/redaction.py +52 -0
- mcpsignals/sinks/__init__.py +25 -0
- mcpsignals/sinks/base.py +13 -0
- mcpsignals/sinks/bigquery.py +47 -0
- mcpsignals/sinks/console.py +17 -0
- mcpsignals/sinks/otlp.py +86 -0
- mcpsignals/sinks/postgres.py +115 -0
- mcpsignals-1.0.1.dist-info/METADATA +98 -0
- mcpsignals-1.0.1.dist-info/RECORD +17 -0
- mcpsignals-1.0.1.dist-info/WHEEL +4 -0
- mcpsignals-1.0.1.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|
mcpsignals/error_kind.py
ADDED
|
@@ -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
|
mcpsignals/instrument.py
ADDED
|
@@ -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
|
mcpsignals/redaction.py
ADDED
|
@@ -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
|
mcpsignals/sinks/base.py
ADDED
|
@@ -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)
|
mcpsignals/sinks/otlp.py
ADDED
|
@@ -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,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.
|