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.
Files changed (23) hide show
  1. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/.gitignore +1 -0
  2. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/PKG-INFO +1 -1
  3. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/pyproject.toml +1 -1
  4. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/intent_capture.py +34 -4
  5. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_instrument.py +27 -0
  6. mcpsignals-2.0.2/tests/test_intent_capture.py +53 -0
  7. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/LICENSE +0 -0
  8. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/README.md +0 -0
  9. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/__init__.py +0 -0
  10. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/buffer.py +0 -0
  11. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/error_kind.py +0 -0
  12. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/events.py +0 -0
  13. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/instrument.py +0 -0
  14. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/redaction.py +0 -0
  15. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/__init__.py +0 -0
  16. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/base.py +0 -0
  17. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/bigquery.py +0 -0
  18. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/console.py +0 -0
  19. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/otlp.py +0 -0
  20. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/src/mcpsignals/sinks/postgres.py +0 -0
  21. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_buffer.py +0 -0
  22. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_error_kind.py +0 -0
  23. {mcpsignals-2.0.0 → mcpsignals-2.0.2}/tests/test_redaction.py +0 -0
@@ -7,3 +7,4 @@ __pycache__/
7
7
  .venv/
8
8
  .env
9
9
  .claude/worktrees
10
+ .claude/worktrees
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcpsignals
3
- Version: 2.0.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.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) and
57
- `clean_arguments` has those three keys removed - exactly what the
58
- handler would have received without the library.
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