mcpsignals 2.0.0__tar.gz → 2.0.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.0.0 → mcpsignals-2.0.2}/.gitignore +1 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/PKG-INFO +1 -1
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/pyproject.toml +1 -1
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/intent_capture.py +34 -4
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_instrument.py +27 -0
- mcpsignals-2.0.2/tests/test_intent_capture.py +53 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/LICENSE +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/README.md +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/__init__.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/buffer.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/error_kind.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/events.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/instrument.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/redaction.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/__init__.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/base.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/bigquery.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/console.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/otlp.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/postgres.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_buffer.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_error_kind.py +0 -0
- {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_redaction.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcpsignals
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.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
|
|
@@ -8,7 +8,7 @@ name = "mcpsignals"
|
|
|
8
8
|
# stamps the real version in at release time and never commits it back, exactly
|
|
9
9
|
# as packages/node/package.json is left at 0.1.0. Do not read this as the
|
|
10
10
|
# published version - check PyPI or the git tags for that.
|
|
11
|
-
version = "2.0.
|
|
11
|
+
version = "2.0.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"
|
|
@@ -13,6 +13,26 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
INJECTED_KEYS = ("session_id", "agent_id", "intent")
|
|
15
15
|
|
|
16
|
+
# Length caps on the three values the calling agent supplies. Without them
|
|
17
|
+
# these are the only unbounded caller-controlled strings on an event, and a
|
|
18
|
+
# sink that batches writes in a transaction (Cloudflare D1's `batch()`, for
|
|
19
|
+
# one) loses every unrelated event in the flush when one oversized value fails
|
|
20
|
+
# its statement. Capping here rather than in each sink means every sink,
|
|
21
|
+
# including third-party and future ones, inherits the bound.
|
|
22
|
+
#
|
|
23
|
+
# `intent` is prose and takes the same 2000 as `error_message`.
|
|
24
|
+
# `session_id`/`agent_id` are identifiers - a UUID is 36 chars - so they take a
|
|
25
|
+
# much tighter cap. Oversized values are truncated rather than dropped: losing
|
|
26
|
+
# the tail of an id is cheaper than losing the event.
|
|
27
|
+
MAX_INTENT_LENGTH = 2000
|
|
28
|
+
MAX_IDENTIFIER_LENGTH = 128
|
|
29
|
+
|
|
30
|
+
_MAX_LENGTHS = {
|
|
31
|
+
"session_id": MAX_IDENTIFIER_LENGTH,
|
|
32
|
+
"agent_id": MAX_IDENTIFIER_LENGTH,
|
|
33
|
+
"intent": MAX_INTENT_LENGTH,
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
_INJECTED_PROPERTIES = {
|
|
17
37
|
"session_id": {
|
|
18
38
|
"type": "string",
|
|
@@ -49,14 +69,24 @@ def inject_schema(input_schema: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
49
69
|
return schema
|
|
50
70
|
|
|
51
71
|
|
|
72
|
+
def _bounded(value: Any, max_length: int) -> str | None:
|
|
73
|
+
# A caller can send any JSON type for these keys. Anything that isn't a
|
|
74
|
+
# string can't be length-bounded, so it's dropped rather than forwarded
|
|
75
|
+
# into an event field typed `str | None`.
|
|
76
|
+
if not isinstance(value, str):
|
|
77
|
+
return None
|
|
78
|
+
return value[:max_length]
|
|
79
|
+
|
|
80
|
+
|
|
52
81
|
def strip_injected(
|
|
53
82
|
arguments: Mapping[str, Any] | None,
|
|
54
83
|
) -> tuple[dict[str, Any], dict[str, str | None]]:
|
|
55
84
|
"""Split raw arguments into (clean_arguments, extracted) where
|
|
56
|
-
`extracted` holds session_id/agent_id/intent (None if absent
|
|
57
|
-
`clean_arguments` has those
|
|
58
|
-
handler would have received without
|
|
85
|
+
`extracted` holds session_id/agent_id/intent (None if absent, non-string
|
|
86
|
+
or length-bounded per `_MAX_LENGTHS`) and `clean_arguments` has those
|
|
87
|
+
three keys removed - exactly what the handler would have received without
|
|
88
|
+
the library.
|
|
59
89
|
"""
|
|
60
90
|
args = dict(arguments) if arguments else {}
|
|
61
|
-
extracted = {key: args.pop(key, None) for key in INJECTED_KEYS}
|
|
91
|
+
extracted = {key: _bounded(args.pop(key, None), _MAX_LENGTHS[key]) for key in INJECTED_KEYS}
|
|
62
92
|
return args, extracted
|
|
@@ -7,6 +7,7 @@ from mcp.server.mcpserver.exceptions import ToolError
|
|
|
7
7
|
from mcp.types import Implementation
|
|
8
8
|
from mcpsignals import instrument
|
|
9
9
|
from mcpsignals.events import ToolCallEvent
|
|
10
|
+
from mcpsignals.intent_capture import MAX_IDENTIFIER_LENGTH, MAX_INTENT_LENGTH
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class RecordingSink:
|
|
@@ -217,6 +218,32 @@ async def test_intent_capture_injects_schema_and_strips_before_handler():
|
|
|
217
218
|
assert event.intent == "user asked"
|
|
218
219
|
|
|
219
220
|
|
|
221
|
+
@pytest.mark.asyncio
|
|
222
|
+
async def test_intent_capture_truncates_oversized_values_before_the_sink():
|
|
223
|
+
server, sink = build_server(intent_capture=True)
|
|
224
|
+
|
|
225
|
+
@server.tool()
|
|
226
|
+
def search(query: str) -> str:
|
|
227
|
+
return query
|
|
228
|
+
|
|
229
|
+
async with Client(server) as client:
|
|
230
|
+
await client.call_tool(
|
|
231
|
+
"search",
|
|
232
|
+
{
|
|
233
|
+
"query": "mugs",
|
|
234
|
+
"session_id": "s" * 5000,
|
|
235
|
+
"agent_id": "a" * 5000,
|
|
236
|
+
"intent": "i" * 5000,
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
await asyncio.sleep(0.05)
|
|
240
|
+
|
|
241
|
+
event = sink.events[0]
|
|
242
|
+
assert event.session_id == "s" * MAX_IDENTIFIER_LENGTH
|
|
243
|
+
assert event.agent_id == "a" * MAX_IDENTIFIER_LENGTH
|
|
244
|
+
assert event.intent == "i" * MAX_INTENT_LENGTH
|
|
245
|
+
|
|
246
|
+
|
|
220
247
|
@pytest.mark.asyncio
|
|
221
248
|
async def test_intent_capture_per_tool_override():
|
|
222
249
|
server, sink = build_server(intent_capture=False, intent_capture_tools={"search": True})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from mcpsignals.intent_capture import (
|
|
2
|
+
MAX_IDENTIFIER_LENGTH,
|
|
3
|
+
MAX_INTENT_LENGTH,
|
|
4
|
+
inject_schema,
|
|
5
|
+
strip_injected,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_strip_injected_truncates_oversized_values():
|
|
10
|
+
_, extracted = strip_injected(
|
|
11
|
+
{
|
|
12
|
+
"query": "mugs",
|
|
13
|
+
"session_id": "s" * 5000,
|
|
14
|
+
"agent_id": "a" * 5000,
|
|
15
|
+
"intent": "i" * 5000,
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
assert extracted["session_id"] == "s" * MAX_IDENTIFIER_LENGTH
|
|
19
|
+
assert extracted["agent_id"] == "a" * MAX_IDENTIFIER_LENGTH
|
|
20
|
+
assert extracted["intent"] == "i" * MAX_INTENT_LENGTH
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_strip_injected_leaves_values_within_the_caps_unchanged():
|
|
24
|
+
_, extracted = strip_injected(
|
|
25
|
+
{
|
|
26
|
+
"session_id": "019609c8-1f57-7000-8000-a1b2c3d4e5f6",
|
|
27
|
+
"agent_id": "agent-1",
|
|
28
|
+
"intent": "i" * MAX_INTENT_LENGTH,
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
assert extracted["session_id"] == "019609c8-1f57-7000-8000-a1b2c3d4e5f6"
|
|
32
|
+
assert extracted["agent_id"] == "agent-1"
|
|
33
|
+
assert len(extracted["intent"]) == MAX_INTENT_LENGTH
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_strip_injected_discards_non_string_values():
|
|
37
|
+
# A caller can send any JSON type for these keys. Anything that isn't a
|
|
38
|
+
# string can't be length-bounded, so it's dropped rather than forwarded
|
|
39
|
+
# into an event field typed `str | None`.
|
|
40
|
+
_, extracted = strip_injected(
|
|
41
|
+
{"session_id": {"nested": "object"}, "agent_id": 42, "intent": ["a", "list"]}
|
|
42
|
+
)
|
|
43
|
+
assert extracted == {"session_id": None, "agent_id": None, "intent": None}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_strip_injected_still_removes_the_keys_from_the_handler_arguments():
|
|
47
|
+
clean, _ = strip_injected({"query": "mugs", "session_id": "s" * 5000, "agent_id": 42})
|
|
48
|
+
assert clean == {"query": "mugs"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_inject_schema_is_unaffected_by_the_caps():
|
|
52
|
+
schema = inject_schema({"type": "object", "properties": {"query": {"type": "string"}}})
|
|
53
|
+
assert set(schema["properties"]) == {"query", "session_id", "agent_id", "intent"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|