mcpsignals 2.3.2__tar.gz → 2.5.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.
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/PKG-INFO +10 -12
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/README.md +9 -11
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/pyproject.toml +1 -1
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/__init__.py +6 -1
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/error_kind.py +9 -1
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/events.py +30 -2
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/instrument.py +203 -20
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/sinks/otlp.py +63 -9
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/sinks/postgres.py +16 -0
- mcpsignals-2.5.0/src/mcpsignals/trace_context.py +29 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_error_kind.py +18 -0
- mcpsignals-2.5.0/tests/test_fields.py +315 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_instrument.py +275 -2
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_otlp.py +60 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/uv.lock +34 -34
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/.gitignore +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/LICENSE +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/buffer.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/handle.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/intent_capture.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/redaction.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/sinks/__init__.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/sinks/base.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/sinks/bigquery.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/src/mcpsignals/sinks/console.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_bigquery.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_buffer.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_console.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_intent_capture.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.0}/tests/test_postgres.py +0 -0
- {mcpsignals-2.3.2 → mcpsignals-2.5.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
|
+
Version: 2.5.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
|
|
64
|
-
|
|
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
|
-
##
|
|
194
|
+
## `session_id`
|
|
194
195
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
##
|
|
168
|
+
## `session_id`
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
11
|
+
version = "2.5.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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
the
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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`.
|
|
231
|
-
#
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
]
|