mcpsignals 2.3.2__tar.gz → 2.4.0__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 (31) hide show
  1. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/PKG-INFO +10 -12
  2. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/README.md +9 -11
  3. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/pyproject.toml +1 -1
  4. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/__init__.py +6 -1
  5. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/error_kind.py +9 -1
  6. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/events.py +30 -2
  7. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/instrument.py +203 -20
  8. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/sinks/otlp.py +63 -9
  9. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/sinks/postgres.py +16 -0
  10. mcpsignals-2.4.0/src/mcpsignals/trace_context.py +29 -0
  11. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_error_kind.py +18 -0
  12. mcpsignals-2.4.0/tests/test_fields.py +315 -0
  13. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_instrument.py +275 -2
  14. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_otlp.py +60 -0
  15. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/uv.lock +22 -22
  16. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/.gitignore +0 -0
  17. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/LICENSE +0 -0
  18. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/buffer.py +0 -0
  19. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/handle.py +0 -0
  20. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/intent_capture.py +0 -0
  21. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/redaction.py +0 -0
  22. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/sinks/__init__.py +0 -0
  23. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/sinks/base.py +0 -0
  24. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/sinks/bigquery.py +0 -0
  25. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/src/mcpsignals/sinks/console.py +0 -0
  26. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_bigquery.py +0 -0
  27. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_buffer.py +0 -0
  28. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_console.py +0 -0
  29. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_intent_capture.py +0 -0
  30. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_postgres.py +0 -0
  31. {mcpsignals-2.3.2 → mcpsignals-2.4.0}/tests/test_redaction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcpsignals
3
- Version: 2.3.2
3
+ Version: 2.4.0
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
@@ -60,8 +60,9 @@ def search(query: str) -> str:
60
60
  Call `instrument()` any time before the server starts handling requests. It
61
61
  appends to `server.middleware`, and that chain is rebuilt from the live list
62
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.
63
+ definitions makes no difference. The Node.js package differs here: it reads
64
+ tool annotations and intent capture from wrapping `registerTool`, so it has
65
+ to run before any tool is registered.
65
66
 
66
67
  With no sink configured it writes JSON lines to stdout. On a stdio
67
68
  transport pass `sinks=[ConsoleSink(stream=sys.stderr)]`, because the MCP
@@ -190,16 +191,13 @@ interval mode, `close()` is also what stops the interval task and removes the
190
191
  accumulate either. Both `flush()` and `close()` are safe to await more than
191
192
  once.
192
193
 
193
- ## Known limitation: `session_id`
194
+ ## `session_id`
194
195
 
195
- `mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
196
- expose the transport's connection-level session id (only the
197
- handler-facing `Context` class does, via a private accessor middleware
198
- doesn't have). `session_id` on emitted events is therefore only ever
199
- populated when intent capture is enabled and the calling agent supplies
200
- one - not from the underlying transport connection. See `instrument.py`
201
- for details; this will be revisited if/when upstream exposes it to
202
- middleware.
196
+ On a stateful streamable HTTP connection, `session_id` is the
197
+ `Mcp-Session-Id` header the client sends with every request after
198
+ `initialize`. It wins over a `session_id` the calling agent supplies through
199
+ intent capture. On stdio there is no transport session, so `session_id` is
200
+ only set when intent capture is enabled and the agent supplies one.
203
201
 
204
202
  ## Full docs
205
203
 
@@ -34,8 +34,9 @@ def search(query: str) -> str:
34
34
  Call `instrument()` any time before the server starts handling requests. It
35
35
  appends to `server.middleware`, and that chain is rebuilt from the live list
36
36
  on every request, so calling it before or after your `@server.tool()`
37
- definitions makes no difference. The Node.js package differs here: it wraps
38
- `registerTool`, so it has to run before any tool is registered.
37
+ definitions makes no difference. The Node.js package differs here: it reads
38
+ tool annotations and intent capture from wrapping `registerTool`, so it has
39
+ to run before any tool is registered.
39
40
 
40
41
  With no sink configured it writes JSON lines to stdout. On a stdio
41
42
  transport pass `sinks=[ConsoleSink(stream=sys.stderr)]`, because the MCP
@@ -164,16 +165,13 @@ interval mode, `close()` is also what stops the interval task and removes the
164
165
  accumulate either. Both `flush()` and `close()` are safe to await more than
165
166
  once.
166
167
 
167
- ## Known limitation: `session_id`
168
+ ## `session_id`
168
169
 
169
- `mcp` 2.0.0's middleware-facing `ServerRequestContext` does not publicly
170
- expose the transport's connection-level session id (only the
171
- handler-facing `Context` class does, via a private accessor middleware
172
- doesn't have). `session_id` on emitted events is therefore only ever
173
- populated when intent capture is enabled and the calling agent supplies
174
- one - not from the underlying transport connection. See `instrument.py`
175
- for details; this will be revisited if/when upstream exposes it to
176
- middleware.
170
+ On a stateful streamable HTTP connection, `session_id` is the
171
+ `Mcp-Session-Id` header the client sends with every request after
172
+ `initialize`. It wins over a `session_id` the calling agent supplies through
173
+ intent capture. On stdio there is no transport session, so `session_id` is
174
+ only set when intent capture is enabled and the agent supplies one.
177
175
 
178
176
  ## Full docs
179
177
 
@@ -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.3.2"
11
+ version = "2.4.0"
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"
@@ -1,9 +1,14 @@
1
- from mcpsignals.events import ToolCallEvent
1
+ from mcpsignals.error_kind import is_error_kind
2
+ from mcpsignals.events import ERROR_KIND_META_KEY, ERROR_KINDS, ErrorKind, ToolCallEvent
2
3
  from mcpsignals.handle import InstrumentHandle, handle_for
3
4
  from mcpsignals.instrument import instrument
4
5
  from mcpsignals.redaction import RedactionConfig
5
6
 
6
7
  __all__ = [
8
+ "ERROR_KINDS",
9
+ "ERROR_KIND_META_KEY",
10
+ "ErrorKind",
11
+ "is_error_kind",
7
12
  "instrument",
8
13
  "handle_for",
9
14
  "InstrumentHandle",
@@ -4,13 +4,21 @@ the documented false-positive mode.
4
4
  """
5
5
 
6
6
  import re
7
+ from typing import Any
8
+
9
+ from mcpsignals.events import ERROR_KINDS, ErrorKind
7
10
 
8
11
  _NOT_FOUND = re.compile(r"not found|does not exist|no such", re.IGNORECASE)
9
12
  _EMPTY = re.compile(r"\bempty\b|no results?|nothing found|zero results", re.IGNORECASE)
10
13
  _VALIDATION = re.compile(r"invalid|required|expected|must be|validation|schema", re.IGNORECASE)
11
14
 
12
15
 
13
- def classify_error(message: str | None) -> str | None:
16
+ def is_error_kind(value: Any) -> bool:
17
+ """True when `value` is one of `ERROR_KINDS`."""
18
+ return isinstance(value, str) and value in ERROR_KINDS
19
+
20
+
21
+ def classify_error(message: str | None) -> ErrorKind | None:
14
22
  """Bucket an error message into not_found / empty / validation / internal.
15
23
 
16
24
  Returns None when there is no message to classify (i.e. the call succeeded).
@@ -2,7 +2,27 @@
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  from datetime import datetime
5
- from typing import Any, Literal
5
+ from typing import Any, Literal, get_args
6
+
7
+ #: Every value `error_kind` can take. `classify_error` only produces some of
8
+ #: them; see schema/events.md.
9
+ ErrorKind = Literal[
10
+ "not_found",
11
+ "empty",
12
+ "validation",
13
+ "auth_required",
14
+ "payment_required",
15
+ "internal",
16
+ ]
17
+ ERROR_KINDS: tuple[ErrorKind, ...] = get_args(ErrorKind)
18
+
19
+ #: A `tools/call` result's `resultType` (protocol revision 2026-07-28).
20
+ #: Older revisions only ever produce `complete`.
21
+ ResultType = Literal["complete", "input_required"]
22
+
23
+ #: `_meta` key a tool handler sets on an `isError` result to record the
24
+ #: `error_kind` it already knows, instead of relying on `classify_error`.
25
+ ERROR_KIND_META_KEY = "mcpsignals/error_kind"
6
26
 
7
27
 
8
28
  @dataclass
@@ -20,13 +40,21 @@ class ToolCallEvent:
20
40
  org_id: str | None = None
21
41
  duration_ms: int = 0
22
42
  success: bool = True
23
- error_kind: str | None = None
43
+ error_kind: ErrorKind | None = None
24
44
  error_message: str | None = None
25
45
  request_bytes: int = 0
26
46
  response_bytes: int = 0
27
47
  arguments: dict[str, Any] | None = None
28
48
  intent: str | None = None
29
49
  transport: str | None = None
50
+ protocol_version: str | None = None
51
+ request_id: str | None = None
52
+ trace_id: str | None = None
53
+ parent_span_id: str | None = None
54
+ result_type: ResultType | None = None
55
+ error_code: int | None = None
56
+ read_only_hint: bool | None = None
57
+ destructive_hint: bool | None = None
30
58
 
31
59
 
32
60
  #: What a sink receives. There is one event type today, so this is an alias
@@ -5,19 +5,13 @@ present on both `MCPServer` and the low-level `Server` - the same mechanism
5
5
  works for both, no separate code paths needed. See
6
6
  https://py.sdk.modelcontextprotocol.io/v2/advanced/middleware/.
7
7
 
8
- Known SDK limitation (first verified against mcp==2.0.0, re-checked against
9
- the mcp==2.2.0 that a fresh `uv sync` resolves; mcp/server/context.py):
10
- `ServerRequestContext` - what middleware receives - does not publicly expose
11
- the transport's connection-level session id or a `connection` accessor
12
- (only the handler-facing `Context` class does, via a private `Connection`
13
- it doesn't share with middleware; `ctx.session` is a `ServerSession`, which
14
- has no session id either). We do not reach into that private attribute. As
15
- a result `session_id` on emitted events is only ever populated from the
16
- optional intent-capture value the calling agent supplies - it is `None` for
17
- calls where intent capture is off or the caller didn't pass one, even though
18
- the connection may well have a real session id.
19
- Track https://github.com/modelcontextprotocol/python-sdk for this being
20
- exposed to middleware in a future release.
8
+ `session_id`: `ServerRequestContext` - what middleware receives - has no
9
+ session id accessor (verified against mcp==2.2.0; mcp/server/context.py).
10
+ It does carry the HTTP request on the streamable HTTP path, so the
11
+ middleware reads the `Mcp-Session-Id` header the client echoes back on
12
+ every request after `initialize`. On stdio there is no transport session,
13
+ and `session_id` comes only from the optional intent-capture value the
14
+ calling agent supplies.
21
15
 
22
16
  Guarantee: nothing the library does around a tool call can change what the
23
17
  client receives. The real handler always runs, its result is returned
@@ -31,6 +25,7 @@ arguments. A sink failure is handled separately by EventBuffer, also logged
31
25
  once per sink.
32
26
  """
33
27
 
28
+ import asyncio
34
29
  import inspect
35
30
  import json
36
31
  import logging
@@ -40,9 +35,13 @@ from dataclasses import replace
40
35
  from datetime import datetime, timezone
41
36
  from typing import Any
42
37
 
38
+ from mcp.server.mcpserver import MCPServer
39
+ from mcp.shared.exceptions import MCPError
40
+ from pydantic import ValidationError
41
+
43
42
  from mcpsignals.buffer import EventBuffer
44
- from mcpsignals.error_kind import classify_error
45
- from mcpsignals.events import ToolCallEvent
43
+ from mcpsignals.error_kind import classify_error, is_error_kind
44
+ from mcpsignals.events import ERROR_KIND_META_KEY, ErrorKind, ResultType, ToolCallEvent
46
45
  from mcpsignals.handle import InstrumentHandle
47
46
  from mcpsignals.handle import register as _register_handle
48
47
  from mcpsignals.intent_capture import (
@@ -55,6 +54,13 @@ from mcpsignals.intent_capture import (
55
54
  from mcpsignals.redaction import RedactionConfig, redact_arguments
56
55
  from mcpsignals.sinks.base import Sink
57
56
  from mcpsignals.sinks.console import ConsoleSink
57
+ from mcpsignals.trace_context import parse_traceparent
58
+
59
+ #: JSON-RPC's code for invalid params, which the SDK answers a pydantic
60
+ #: `ValidationError` with.
61
+ _INVALID_PARAMS = -32602
62
+
63
+ ToolHints = tuple[bool | None, bool | None]
58
64
 
59
65
  logger = logging.getLogger("mcpsignals")
60
66
 
@@ -78,6 +84,19 @@ def _result_content(result: Any) -> Any:
78
84
  return getattr(result, "content", None)
79
85
 
80
86
 
87
+ def _declared_error_kind(result: Any) -> ErrorKind | None:
88
+ """The `error_kind` a handler set in the result's `_meta`, if it is a known value."""
89
+ # Same two shapes as `_is_error_result`: wire-shaped (`_meta`) or snake_case (`meta`).
90
+ if isinstance(result, Mapping):
91
+ meta = result.get("_meta")
92
+ if meta is None:
93
+ meta = result.get("meta")
94
+ else:
95
+ meta = getattr(result, "meta", None)
96
+ kind = meta.get(ERROR_KIND_META_KEY) if isinstance(meta, Mapping) else None
97
+ return kind if is_error_kind(kind) else None
98
+
99
+
81
100
  def _content_to_text(content: Any) -> str | None:
82
101
  if not content:
83
102
  return None
@@ -89,6 +108,56 @@ def _content_to_text(content: Any) -> str | None:
89
108
  return "\n".join(parts) if parts else None
90
109
 
91
110
 
111
+ def _result_type(result: Any) -> ResultType:
112
+ """`input_required` for a 2026-07-28 multi-round-trip result, else `complete`."""
113
+ if isinstance(result, Mapping):
114
+ value = result.get("resultType", result.get("result_type"))
115
+ else:
116
+ value = getattr(result, "result_type", None)
117
+ return "input_required" if value == "input_required" else "complete"
118
+
119
+
120
+ def _error_code(exc: BaseException) -> int | None:
121
+ """The JSON-RPC code the SDK answers `exc` with, where every transport
122
+ agrees on it. Any other exception is answered with a code that depends on
123
+ the transport (0 or -32603), so it is recorded as None."""
124
+ if isinstance(exc, MCPError):
125
+ return exc.error.code
126
+ if isinstance(exc, ValidationError):
127
+ return _INVALID_PARAMS
128
+ return None
129
+
130
+
131
+ def _as_bool(value: Any) -> bool | None:
132
+ return value if isinstance(value, bool) else None
133
+
134
+
135
+ def _tool_hints(tool: Any) -> tuple[str | None, ToolHints]:
136
+ """A `tools/list` entry's name and its read-only / destructive hints.
137
+ Handles the wire dict (camelCase) and the `Tool` model (snake_case)."""
138
+ if isinstance(tool, Mapping):
139
+ name = tool.get("name")
140
+ annotations = tool.get("annotations")
141
+ else:
142
+ name = getattr(tool, "name", None)
143
+ annotations = getattr(tool, "annotations", None)
144
+ if isinstance(annotations, Mapping):
145
+ read_only = annotations.get("readOnlyHint", annotations.get("read_only_hint"))
146
+ destructive = annotations.get("destructiveHint", annotations.get("destructive_hint"))
147
+ else:
148
+ read_only = getattr(annotations, "read_only_hint", None)
149
+ destructive = getattr(annotations, "destructive_hint", None)
150
+ return (name if isinstance(name, str) else None), (_as_bool(read_only), _as_bool(destructive))
151
+
152
+
153
+ def _session_id_header(request: Any) -> str | None:
154
+ """The `Mcp-Session-Id` header of an HTTP request, capped, or None."""
155
+ headers = getattr(request, "headers", None)
156
+ if headers is None:
157
+ return None
158
+ return bounded(headers.get("mcp-session-id"), MAX_IDENTIFIER_LENGTH) or None
159
+
160
+
92
161
  def _serialize_for_bytes(value: Any) -> bytes:
93
162
  if value is None:
94
163
  return b""
@@ -143,9 +212,75 @@ def instrument(
143
212
  exc,
144
213
  )
145
214
 
215
+ # Tool name -> (read_only_hint, destructive_hint). Middleware sees only the
216
+ # request, not the registration, so hints come from the `tools/list`
217
+ # results passing through. On `MCPServer`, the first miss also lists the
218
+ # server's tools once; a name still missing after that is unknown and is
219
+ # not listed again. `add_tool` and `remove_tool` clear the cache, so a tool
220
+ # re-added with other annotations is read fresh. On a low-level `Server`
221
+ # the hints are null until the client calls `tools/list`.
222
+ tool_hints: dict[str, ToolHints] = {}
223
+ # The one listing since the last registration change. Concurrent misses
224
+ # await the same task, so none of them reads a half-filled cache.
225
+ listing: asyncio.Task[None] | None = None
226
+
227
+ def _remember_hints(tools: Any) -> None:
228
+ for tool in tools or []:
229
+ name, hints = _tool_hints(tool)
230
+ if name is not None:
231
+ tool_hints[name] = hints
232
+
233
+ async def _list_hints() -> None:
234
+ tools = await server.list_tools()
235
+ # A registration change while this ran makes the result stale.
236
+ if listing is asyncio.current_task():
237
+ _remember_hints(tools)
238
+
239
+ async def _hints_for(name: str) -> ToolHints:
240
+ nonlocal listing
241
+ if name not in tool_hints and isinstance(server, MCPServer):
242
+ if listing is None:
243
+ listing = asyncio.ensure_future(_list_hints())
244
+ task = listing
245
+ try:
246
+ # Shielded: a cancelled call must not cancel the shared listing.
247
+ await asyncio.shield(task)
248
+ except Exception:
249
+ # A failed listing is retried on the next miss.
250
+ if listing is task:
251
+ listing = None
252
+ raise
253
+ return tool_hints.get(name, (None, None))
254
+
255
+ def _forget_hints() -> None:
256
+ nonlocal listing
257
+ tool_hints.clear()
258
+ listing = None
259
+
260
+ if isinstance(server, MCPServer):
261
+ # `@server.tool()` registers through `add_tool`, so this sees every change.
262
+ for method_name in ("add_tool", "remove_tool"):
263
+ original = getattr(server, method_name)
264
+
265
+ def _invalidating(*args: Any, _original: Any = original, **kwargs: Any) -> Any:
266
+ result = _original(*args, **kwargs)
267
+ _forget_hints()
268
+ return result
269
+
270
+ setattr(server, method_name, _invalidating)
271
+
146
272
  async def _mcpsignals_middleware(ctx, call_next):
147
273
  if ctx.method == "tools/list":
148
274
  result = await call_next(ctx)
275
+ try:
276
+ tools = (
277
+ result.get("tools")
278
+ if isinstance(result, Mapping)
279
+ else getattr(result, "tools", None)
280
+ )
281
+ _remember_hints(tools)
282
+ except Exception as exc: # noqa: BLE001 - never changes the tools/list result
283
+ _warn_once("tool hints", exc)
149
284
  if intent_capture or intent_capture_tools:
150
285
  # `call_next` returns a wire-shaped dict here (camelCase keys),
151
286
  # not a ListToolsResult/Tool instance - verified against the
@@ -218,7 +353,25 @@ def instrument(
218
353
  client_name = bounded(client_info.name, MAX_IDENTIFIER_LENGTH)
219
354
  client_version = bounded(client_info.version, MAX_IDENTIFIER_LENGTH)
220
355
 
221
- transport = "http" if getattr(ctx, "request", None) is not None else "stdio"
356
+ http_request = getattr(ctx, "request", None)
357
+ transport = "http" if http_request is not None else "stdio"
358
+ # The transport's session: the `Mcp-Session-Id` header a stateful
359
+ # streamable HTTP client echoes back on every request. A stateless
360
+ # server accepts any value here, so it takes the identifier cap.
361
+ transport_session_id = _session_id_header(http_request)
362
+
363
+ # Revision 2026-07-28 carries the version on every request's envelope;
364
+ # earlier revisions negotiate it in `initialize`. Either way the client
365
+ # supplied it, so it takes the identifier cap, as does the request id.
366
+ protocol_version = bounded(getattr(ctx, "protocol_version", None), MAX_IDENTIFIER_LENGTH)
367
+ request_id = getattr(ctx, "request_id", None)
368
+ request_id = (
369
+ bounded(str(request_id), MAX_IDENTIFIER_LENGTH)
370
+ if isinstance(request_id, (str, int)) and not isinstance(request_id, bool)
371
+ else None
372
+ )
373
+ meta = getattr(ctx, "meta", None)
374
+ trace = parse_traceparent(meta.get("traceparent") if isinstance(meta, Mapping) else None)
222
375
 
223
376
  async def _record(ts: datetime, error: BaseException | None, result: Any) -> None:
224
377
  # First thing: the handler has just settled, so this is the
@@ -227,8 +380,9 @@ def instrument(
227
380
  duration_ms = int((time.perf_counter() - start) * 1000)
228
381
 
229
382
  # Runs after the handler so its latency never lands in
230
- # `duration_ms`. A host resolver that raises records a null
231
- # identity; the handler's result is already on its way back.
383
+ # `duration_ms`. It still runs before the result is returned, so
384
+ # the client waits for it. A host resolver that raises records a
385
+ # null identity.
232
386
  user_id: str | None = None
233
387
  org_id: str | None = None
234
388
  if resolve_identity is not None:
@@ -242,15 +396,36 @@ def instrument(
242
396
  _warn_once("resolve_identity", exc)
243
397
  user_id, org_id = None, None
244
398
 
399
+ # After the timed window too. The first miss after a registration
400
+ # change lists the server's tools, and the client waits for that one.
401
+ try:
402
+ read_only_hint, destructive_hint = await _hints_for(raw_tool_name)
403
+ except Exception as exc: # noqa: BLE001 - a hint lookup never costs the event
404
+ _warn_once("tool hints", exc)
405
+ read_only_hint, destructive_hint = None, None
406
+
407
+ declared_kind: ErrorKind | None = None
408
+ result_type: ResultType | None = None
409
+ error_code: int | None = None
245
410
  if error is not None:
246
411
  success = False
247
412
  error_message: str | None = str(error)[:2000]
248
413
  response_bytes = 0
414
+ error_code = _error_code(error)
249
415
  else:
416
+ result_type = _result_type(result)
250
417
  success = not _is_error_result(result)
251
418
  error_message = None if success else _content_to_text(_result_content(result))
252
419
  if error_message:
253
420
  error_message = error_message[:2000]
421
+ # A kind the handler declared wins; an unknown value falls back to the heuristic.
422
+ # Guarded on its own so a throwing `_meta` costs only the
423
+ # declared kind, not the event.
424
+ if not success:
425
+ try:
426
+ declared_kind = _declared_error_kind(result)
427
+ except Exception as exc:
428
+ _warn_once("declared error kind", exc)
254
429
  response_bytes = len(_serialize_for_bytes(result))
255
430
 
256
431
  # Guarded on its own so a broken redactor still leaves an event
@@ -283,7 +458,7 @@ def instrument(
283
458
  server_name=server_name,
284
459
  server_version=server_version,
285
460
  tool_name=tool_name,
286
- session_id=extracted.get("session_id"),
461
+ session_id=transport_session_id or extracted.get("session_id"),
287
462
  agent_id=extracted.get("agent_id"),
288
463
  client_name=client_name,
289
464
  client_version=client_version,
@@ -291,13 +466,21 @@ def instrument(
291
466
  org_id=org_id,
292
467
  duration_ms=duration_ms,
293
468
  success=success,
294
- error_kind=classify_error(error_message),
469
+ error_kind=declared_kind or classify_error(error_message),
295
470
  error_message=error_message,
296
471
  request_bytes=request_bytes,
297
472
  response_bytes=response_bytes,
298
473
  arguments=arguments,
299
474
  intent=extracted.get("intent"),
300
475
  transport=transport,
476
+ protocol_version=protocol_version,
477
+ request_id=request_id,
478
+ trace_id=trace[0] if trace else None,
479
+ parent_span_id=trace[1] if trace else None,
480
+ result_type=result_type,
481
+ error_code=error_code,
482
+ read_only_hint=read_only_hint,
483
+ destructive_hint=destructive_hint,
301
484
  )
302
485
  await buffer.add(event)
303
486
 
@@ -1,15 +1,16 @@
1
1
  """OTLP sink. Requires the `otlp` extra (opentelemetry-api only, not an SDK
2
2
  or exporter - relies on whatever global TracerProvider the host app already
3
- configured, the standard "zero-code" OTel pattern). Emits one root span per
4
- tool call; spans are never parented to the context current at flush time
5
- (see the note at `start_span` in `write`).
3
+ configured, the standard "zero-code" OTel pattern). Emits one span per tool
4
+ call: a child of the caller's span when the request carried a `traceparent`,
5
+ a root span otherwise. Spans are never parented to the context current at
6
+ flush time (see the note at `start_span` in `write`).
6
7
 
7
8
  Field mapping verified against the live OpenTelemetry GenAI semantic
8
9
  conventions for MCP (open-telemetry/semantic-conventions-genai,
9
10
  docs/gen-ai/mcp.md) as of this writing. Everything in that document is
10
11
  Development/Experimental status, not Stable, except where noted below.
11
12
  Where our schema has no equivalent convention, we use a custom
12
- `mcpsignals.*` attribute rather than force a bad fit (see `transport`).
13
+ `mcpsignals.*` attribute rather than force a bad fit.
13
14
  """
14
15
 
15
16
  import json
@@ -17,6 +18,17 @@ import json
17
18
  from mcpsignals.events import ToolCallEvent
18
19
 
19
20
 
21
+ def _network_attributes(transport: str | None) -> dict[str, str]:
22
+ """The MCP semconv's "Recording MCP transport" table: stdio is `pipe`,
23
+ streamable HTTP is `tcp` with `network.protocol.name` `http`. The
24
+ original value also stays on `mcpsignals.transport`."""
25
+ if transport == "stdio":
26
+ return {"network.transport": "pipe"}
27
+ if transport == "http":
28
+ return {"network.transport": "tcp", "network.protocol.name": "http"}
29
+ return {}
30
+
31
+
20
32
  class OtlpSink:
21
33
  def __init__(self, tracer=None):
22
34
  if tracer is None:
@@ -27,12 +39,22 @@ class OtlpSink:
27
39
 
28
40
  async def write(self, events: list[ToolCallEvent]) -> None:
29
41
  from opentelemetry.context import Context
30
- from opentelemetry.trace import SpanKind, Status, StatusCode
42
+ from opentelemetry.trace import (
43
+ NonRecordingSpan,
44
+ SpanContext,
45
+ SpanKind,
46
+ Status,
47
+ StatusCode,
48
+ TraceFlags,
49
+ set_span_in_context,
50
+ )
31
51
 
32
52
  for event in events:
33
53
  attributes = {
54
+ "mcp.method.name": "tools/call",
34
55
  "gen_ai.operation.name": "execute_tool",
35
56
  "gen_ai.tool.name": event.tool_name,
57
+ **_network_attributes(event.transport),
36
58
  "mcpsignals.server.name": event.server_name,
37
59
  "mcpsignals.request.bytes": event.request_bytes,
38
60
  "mcpsignals.response.bytes": event.response_bytes,
@@ -54,11 +76,27 @@ class OtlpSink:
54
76
  if event.org_id:
55
77
  attributes["mcpsignals.org.id"] = event.org_id
56
78
  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
79
  attributes["mcpsignals.transport"] = event.transport
60
80
  if event.intent:
61
81
  attributes["mcpsignals.intent"] = event.intent
82
+ if not event.success:
83
+ # A JSON-RPC error's code, else the semconv value for an
84
+ # `isError` tool result. Same as the Node sink.
85
+ if event.error_code is not None:
86
+ attributes["error.type"] = str(event.error_code)
87
+ attributes["rpc.response.status_code"] = str(event.error_code)
88
+ else:
89
+ attributes["error.type"] = "tool_error"
90
+ if event.protocol_version:
91
+ attributes["mcp.protocol.version"] = event.protocol_version
92
+ if event.request_id:
93
+ attributes["jsonrpc.request.id"] = event.request_id
94
+ if event.result_type:
95
+ attributes["mcpsignals.result.type"] = event.result_type
96
+ if event.read_only_hint is not None:
97
+ attributes["mcpsignals.tool.read_only_hint"] = event.read_only_hint
98
+ if event.destructive_hint is not None:
99
+ attributes["mcpsignals.tool.destructive_hint"] = event.destructive_hint
62
100
  if event.error_kind:
63
101
  attributes["mcpsignals.error.kind"] = event.error_kind
64
102
  if event.arguments is not None:
@@ -74,10 +112,26 @@ class OtlpSink:
74
112
  # The batch is usually written from inside whatever request handler
75
113
  # pushed the last event, so the current context (the default
76
114
  # parent) belongs to an unrelated span. Start from an empty
77
- # Context so every tool call is its own root span.
115
+ # Context, or from the caller's span when the request carried a
116
+ # `traceparent`. The event does not keep the caller's trace flags,
117
+ # so the remote parent is marked sampled and the host's sampler
118
+ # decides.
119
+ parent = Context()
120
+ if event.trace_id and event.parent_span_id:
121
+ parent = set_span_in_context(
122
+ NonRecordingSpan(
123
+ SpanContext(
124
+ trace_id=int(event.trace_id, 16),
125
+ span_id=int(event.parent_span_id, 16),
126
+ is_remote=True,
127
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
128
+ )
129
+ ),
130
+ parent,
131
+ )
78
132
  span = self._tracer.start_span(
79
133
  f"tools/call {event.tool_name}",
80
- context=Context(),
134
+ context=parent,
81
135
  kind=SpanKind.SERVER,
82
136
  attributes=attributes,
83
137
  start_time=start_ns,
@@ -26,6 +26,14 @@ _TOOL_CALL_COLUMNS = [
26
26
  "arguments",
27
27
  "intent",
28
28
  "transport",
29
+ "protocol_version",
30
+ "request_id",
31
+ "trace_id",
32
+ "parent_span_id",
33
+ "result_type",
34
+ "error_code",
35
+ "read_only_hint",
36
+ "destructive_hint",
29
37
  ]
30
38
 
31
39
 
@@ -80,6 +88,14 @@ class PostgresSink:
80
88
  _json.dumps(e.arguments) if e.arguments is not None else None,
81
89
  e.intent,
82
90
  e.transport,
91
+ e.protocol_version,
92
+ e.request_id,
93
+ e.trace_id,
94
+ e.parent_span_id,
95
+ e.result_type,
96
+ e.error_code,
97
+ e.read_only_hint,
98
+ e.destructive_hint,
83
99
  )
84
100
  for e in tool_calls
85
101
  ]