mcpspan 0.1.0__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.
- mcpspan/__init__.py +34 -0
- mcpspan/_call.py +49 -0
- mcpspan/_client.py +148 -0
- mcpspan/_config.py +276 -0
- mcpspan/_failure.py +110 -0
- mcpspan/_fastmcp.py +297 -0
- mcpspan/_instrument.py +82 -0
- mcpspan/_marks.py +51 -0
- mcpspan/_official.py +240 -0
- mcpspan/_parameters.py +58 -0
- mcpspan/_primitives.py +363 -0
- mcpspan/_queue.py +63 -0
- mcpspan/_reporter.py +290 -0
- mcpspan/_session.py +89 -0
- mcpspan/_track.py +390 -0
- mcpspan/_transport.py +152 -0
- mcpspan/_types.py +54 -0
- mcpspan/_version.py +6 -0
- mcpspan/py.typed +0 -0
- mcpspan-0.1.0.dist-info/METADATA +309 -0
- mcpspan-0.1.0.dist-info/RECORD +23 -0
- mcpspan-0.1.0.dist-info/WHEEL +4 -0
- mcpspan-0.1.0.dist-info/licenses/LICENSE +21 -0
mcpspan/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Analytics for MCP servers.
|
|
2
|
+
|
|
3
|
+
The usual integration is one line at startup:
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
import mcpspan
|
|
7
|
+
|
|
8
|
+
mcp = FastMCP("flights")
|
|
9
|
+
mcpspan.instrument(mcp, api_key=os.environ["MCPSPAN_API_KEY"])
|
|
10
|
+
|
|
11
|
+
Every tool on the server is measured from then on, whether it was registered
|
|
12
|
+
before that line or after. Without an API key nothing is collected and
|
|
13
|
+
nothing is sent.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._config import configure, shutdown
|
|
17
|
+
from ._instrument import instrument
|
|
18
|
+
from ._track import exclude, track
|
|
19
|
+
from ._types import ClientType, ErrorSource, ToolCallEvent
|
|
20
|
+
from ._version import SDK_VERSION
|
|
21
|
+
|
|
22
|
+
__version__ = SDK_VERSION
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ClientType",
|
|
26
|
+
"ErrorSource",
|
|
27
|
+
"ToolCallEvent",
|
|
28
|
+
"__version__",
|
|
29
|
+
"configure",
|
|
30
|
+
"exclude",
|
|
31
|
+
"instrument",
|
|
32
|
+
"shutdown",
|
|
33
|
+
"track",
|
|
34
|
+
]
|
mcpspan/_call.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._client import ClientInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CallState:
|
|
12
|
+
"""What instrumentation knows about the tool call now running.
|
|
13
|
+
|
|
14
|
+
`track` wraps the developer's function and sees only its arguments. Which
|
|
15
|
+
connection the call arrived on, which client sent it, and the arguments as
|
|
16
|
+
the client sent them are known one layer out, where the MCP server looks
|
|
17
|
+
the tool up. They reach the function's wrapper through a context variable,
|
|
18
|
+
which follows the call into the task or worker thread that runs it and is
|
|
19
|
+
never shared with a concurrent call.
|
|
20
|
+
|
|
21
|
+
`reached` is set by the wrapper, so the layer outside knows whether the
|
|
22
|
+
call got as far as the tool or was refused before it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
session_id: str | None
|
|
26
|
+
client: ClientInfo | None
|
|
27
|
+
arguments: Any
|
|
28
|
+
server_version: str | None = None
|
|
29
|
+
reached: bool = False
|
|
30
|
+
interim: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
current_call: ContextVar[CallState | None] = ContextVar("mcpspan_call", default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
MODERN_PROTOCOL = "2026-07-28"
|
|
37
|
+
"""The first protocol revision on which a tool can ask the client for input."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cannot_ask(request_context: Any) -> bool:
|
|
41
|
+
"""Whether the connection is on a protocol with no way to ask for input.
|
|
42
|
+
|
|
43
|
+
There, an interim `input_required` result cannot be delivered, and the MCP
|
|
44
|
+
SDK answers the client with an error in its place: v2 of the official SDK
|
|
45
|
+
and FastMCP both do. Revisions are dates, so they compare as text.
|
|
46
|
+
"""
|
|
47
|
+
version = getattr(getattr(request_context, "session", None), "protocol_version", None)
|
|
48
|
+
|
|
49
|
+
return isinstance(version, str) and version < MODERN_PROTOCOL
|
mcpspan/_client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any, NamedTuple
|
|
5
|
+
|
|
6
|
+
from ._failure import MAX_NAME_LENGTH, truncate
|
|
7
|
+
from ._types import ClientType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClientInfo(NamedTuple):
|
|
11
|
+
"""How a client described itself: its name and version, as sent.
|
|
12
|
+
|
|
13
|
+
On the 2025 protocol a client says this once, in the `initialize`
|
|
14
|
+
handshake; on the 2026-07-28 protocol it repeats it in every request's
|
|
15
|
+
`_meta`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str | None
|
|
19
|
+
version: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_KNOWN_CLIENTS: tuple[tuple[str, ClientType], ...] = (
|
|
23
|
+
# Measured: Claude Code sends `claude-code`. It has to come before the
|
|
24
|
+
# plain Claude entry, which would otherwise swallow it.
|
|
25
|
+
("claude-code", "claude-code"),
|
|
26
|
+
("claude code", "claude-code"),
|
|
27
|
+
("claude", "claude"),
|
|
28
|
+
("cursor", "cursor"),
|
|
29
|
+
("chatgpt", "chatgpt"),
|
|
30
|
+
("openai", "chatgpt"),
|
|
31
|
+
# Measured: the official Inspector sends `inspector-cli`, which is why
|
|
32
|
+
# names are matched as substrings.
|
|
33
|
+
("inspector", "mcp-inspector"),
|
|
34
|
+
)
|
|
35
|
+
"""Names we recognise, matched as substrings of what a client reports, first
|
|
36
|
+
match wins. The same table as every other mcpspan SDK (contract, section 7)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_client(info: ClientInfo | None) -> ClientType:
|
|
40
|
+
"""Which application a tool call came from, as far as its name tells."""
|
|
41
|
+
name = (info.name or "").strip().lower() if info is not None else ""
|
|
42
|
+
if not name:
|
|
43
|
+
return "unknown"
|
|
44
|
+
|
|
45
|
+
for pattern, client_type in _KNOWN_CLIENTS:
|
|
46
|
+
if pattern in name:
|
|
47
|
+
return client_type
|
|
48
|
+
|
|
49
|
+
return "other"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def client_name(info: ClientInfo | None) -> str | None:
|
|
53
|
+
"""The name a client reported, cut to what the API takes.
|
|
54
|
+
|
|
55
|
+
Kept alongside the recognised type so an unfamiliar client is a lead
|
|
56
|
+
rather than a dead end. The client chooses its own name, and one over the
|
|
57
|
+
API's limit would lose every event in its batch.
|
|
58
|
+
"""
|
|
59
|
+
name = (info.name or "").strip() if info is not None else ""
|
|
60
|
+
|
|
61
|
+
return truncate(name, MAX_NAME_LENGTH) if name else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"
|
|
65
|
+
"""Where a request on the 2026-07-28 protocol names its client."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read(value: Any, *names: str) -> Any:
|
|
69
|
+
"""The first of several spellings of a field, on a model or in a dict."""
|
|
70
|
+
for name in names:
|
|
71
|
+
if isinstance(value, Mapping):
|
|
72
|
+
if name in value:
|
|
73
|
+
return value[name]
|
|
74
|
+
else:
|
|
75
|
+
found = getattr(value, name, None)
|
|
76
|
+
if found is not None:
|
|
77
|
+
return found
|
|
78
|
+
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _as_client_info(value: Any) -> ClientInfo | None:
|
|
83
|
+
name = _read(value, "name")
|
|
84
|
+
version = _read(value, "version")
|
|
85
|
+
|
|
86
|
+
if not isinstance(name, str) and not isinstance(version, str):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return ClientInfo(
|
|
90
|
+
name if isinstance(name, str) else None,
|
|
91
|
+
version if isinstance(version, str) else None,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _declared_in_meta(meta: Any) -> Any:
|
|
96
|
+
"""The client a request's `_meta` names, whatever form `_meta` takes.
|
|
97
|
+
|
|
98
|
+
v2 of the official MCP SDK hands `_meta` over as a dict. v1 keeps it as a
|
|
99
|
+
model, with keys it does not know among its extra fields.
|
|
100
|
+
"""
|
|
101
|
+
if meta is None:
|
|
102
|
+
return None
|
|
103
|
+
if isinstance(meta, Mapping):
|
|
104
|
+
return meta.get(CLIENT_INFO_META_KEY)
|
|
105
|
+
|
|
106
|
+
extra = getattr(meta, "model_extra", None)
|
|
107
|
+
|
|
108
|
+
return extra.get(CLIENT_INFO_META_KEY) if isinstance(extra, Mapping) else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def client_from_request(request_context: Any) -> ClientInfo | None:
|
|
112
|
+
"""The client that sent one request, from the MCP SDK's request context.
|
|
113
|
+
|
|
114
|
+
The request itself is read first: on the 2026-07-28 protocol it names its
|
|
115
|
+
client. Otherwise the handshake of the connection the request arrived on,
|
|
116
|
+
and never any other connection's. A stateless HTTP endpoint on the 2025
|
|
117
|
+
protocol has neither, and its calls are recorded with an unknown client
|
|
118
|
+
rather than a guess.
|
|
119
|
+
|
|
120
|
+
Every read is defensive. Knowing the client is a convenience; a request
|
|
121
|
+
context of an unexpected shape leaves it unknown and nothing else.
|
|
122
|
+
"""
|
|
123
|
+
if request_context is None:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# `meta` first. v2 of the official SDK may keep only part of `_meta`
|
|
128
|
+
# there, and the request's raw `params` still hold all of it.
|
|
129
|
+
params = getattr(request_context, "params", None)
|
|
130
|
+
for meta in (
|
|
131
|
+
getattr(request_context, "meta", None),
|
|
132
|
+
params.get("_meta") if isinstance(params, Mapping) else None,
|
|
133
|
+
):
|
|
134
|
+
declared = _as_client_info(_declared_in_meta(meta))
|
|
135
|
+
if declared is not None:
|
|
136
|
+
return declared
|
|
137
|
+
|
|
138
|
+
# v2 keeps the handshake on the connection; v1, and v2 as well, on the
|
|
139
|
+
# session. `clientInfo` in v1, `client_info` in v2.
|
|
140
|
+
for holder in ("connection", "session"):
|
|
141
|
+
params = _read(getattr(request_context, holder, None), "client_params")
|
|
142
|
+
info = _as_client_info(_read(params, "clientInfo", "client_info"))
|
|
143
|
+
if info is not None:
|
|
144
|
+
return info
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
return None
|
mcpspan/_config.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._reporter import EventReporter
|
|
11
|
+
from ._track import set_capture_parameter_names, set_event_sink, set_server_version
|
|
12
|
+
|
|
13
|
+
NO_ENDPOINT = (
|
|
14
|
+
"mcpspan: an API key is set but no endpoint, so nothing is collected. "
|
|
15
|
+
"Set MCPSPAN_ENDPOINT (or the endpoint option) to your mcpspan installation, "
|
|
16
|
+
"for example http://localhost:6271."
|
|
17
|
+
)
|
|
18
|
+
"""Said when there is a key and nowhere to send: somebody meant to collect.
|
|
19
|
+
|
|
20
|
+
There is no default endpoint, since mcpspan runs wherever its user runs it,
|
|
21
|
+
and a default would send their data somewhere they did not choose.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_lock = threading.RLock()
|
|
25
|
+
_reporter: EventReporter | None = None
|
|
26
|
+
_active: tuple[tuple[Any, ...], Callable[[str], object] | None] | None = None
|
|
27
|
+
_exit_hook_installed = False
|
|
28
|
+
_fork_hook_installed = False
|
|
29
|
+
_said_no_endpoint = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _first_non_empty(*values: str | None) -> str | None:
|
|
33
|
+
for value in values:
|
|
34
|
+
if value is not None and value.strip():
|
|
35
|
+
return value.strip()
|
|
36
|
+
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _positive(name: str, value: Any, debug: bool, *, integer: bool) -> Any:
|
|
41
|
+
"""The value if it is usable, otherwise None and, when asked, a note why."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
kinds: tuple[type, ...] = (int,) if integer else (int, float)
|
|
46
|
+
usable = isinstance(value, kinds) and not isinstance(value, bool)
|
|
47
|
+
if not usable or value <= 0:
|
|
48
|
+
if debug:
|
|
49
|
+
expected = "a positive integer" if integer else "a positive number"
|
|
50
|
+
print(f"mcpspan: ignoring {name}={value!r}, expected {expected}", file=sys.stderr)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def configure(
|
|
57
|
+
*,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
endpoint: str | None = None,
|
|
60
|
+
debug: bool | None = None,
|
|
61
|
+
on_diagnostic: Callable[[str], object] | None = None,
|
|
62
|
+
flush_on_exit: bool = True,
|
|
63
|
+
flush_interval: float | None = None,
|
|
64
|
+
max_batch_size: int | None = None,
|
|
65
|
+
max_queue_size: int | None = None,
|
|
66
|
+
capture_parameter_names: bool = False,
|
|
67
|
+
server_version: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Starts collecting, or stops if there is nothing to collect with.
|
|
70
|
+
|
|
71
|
+
- `api_key`: falls back to `MCPSPAN_API_KEY`. Without either the SDK
|
|
72
|
+
does nothing at all, which is the normal state in development and CI.
|
|
73
|
+
- `endpoint`: base URL of the ingest API; falls back to
|
|
74
|
+
`MCPSPAN_ENDPOINT`. There is no default: without either, nothing is
|
|
75
|
+
collected, and the SDK says so once.
|
|
76
|
+
- `debug`: delivery diagnostics on standard error. `on_diagnostic`
|
|
77
|
+
receives them instead, and implies `debug`.
|
|
78
|
+
- `flush_on_exit`: send what is queued when the interpreter exits.
|
|
79
|
+
- `flush_interval`: seconds a partly filled batch waits (default 5).
|
|
80
|
+
- `max_batch_size`, `max_queue_size`: events per request (100) and held
|
|
81
|
+
while delivery fails (10,000).
|
|
82
|
+
- `capture_parameter_names`: record which parameters a tool was called
|
|
83
|
+
with, by name and type. Off by default; values are never read.
|
|
84
|
+
- `server_version`: the version to record calls under, a release or a
|
|
85
|
+
commit; falls back to `MCPSPAN_SERVER_VERSION`, then to the version the
|
|
86
|
+
server gives itself. The dashboard marks where each version began.
|
|
87
|
+
|
|
88
|
+
Calling it again with the same settings changes nothing. That is the
|
|
89
|
+
common case: a server built per request configures on every request, and
|
|
90
|
+
starting over each time would announce the server once per request.
|
|
91
|
+
Different settings replace the running configuration, sending what the
|
|
92
|
+
old one held.
|
|
93
|
+
|
|
94
|
+
Never raises. It runs during a server's startup, and a mistyped option
|
|
95
|
+
must not be why a server fails to boot.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
_configure(
|
|
99
|
+
api_key=api_key,
|
|
100
|
+
endpoint=endpoint,
|
|
101
|
+
debug=debug,
|
|
102
|
+
on_diagnostic=on_diagnostic,
|
|
103
|
+
flush_on_exit=flush_on_exit,
|
|
104
|
+
flush_interval=flush_interval,
|
|
105
|
+
max_batch_size=max_batch_size,
|
|
106
|
+
max_queue_size=max_queue_size,
|
|
107
|
+
capture_parameter_names=capture_parameter_names,
|
|
108
|
+
server_version=server_version,
|
|
109
|
+
)
|
|
110
|
+
except Exception as error:
|
|
111
|
+
if debug:
|
|
112
|
+
print(f"mcpspan: could not configure ({error})", file=sys.stderr)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _configure(
|
|
116
|
+
*,
|
|
117
|
+
api_key: str | None,
|
|
118
|
+
endpoint: str | None,
|
|
119
|
+
debug: bool | None,
|
|
120
|
+
on_diagnostic: Callable[[str], object] | None,
|
|
121
|
+
flush_on_exit: bool,
|
|
122
|
+
flush_interval: float | None,
|
|
123
|
+
max_batch_size: int | None,
|
|
124
|
+
max_queue_size: int | None,
|
|
125
|
+
capture_parameter_names: bool,
|
|
126
|
+
server_version: str | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
global _reporter, _active, _said_no_endpoint
|
|
129
|
+
|
|
130
|
+
key = _first_non_empty(api_key, os.environ.get("MCPSPAN_API_KEY"))
|
|
131
|
+
url = _first_non_empty(endpoint, os.environ.get("MCPSPAN_ENDPOINT"))
|
|
132
|
+
version = _first_non_empty(server_version, os.environ.get("MCPSPAN_SERVER_VERSION"))
|
|
133
|
+
settings = (
|
|
134
|
+
key,
|
|
135
|
+
url,
|
|
136
|
+
debug,
|
|
137
|
+
flush_on_exit,
|
|
138
|
+
flush_interval,
|
|
139
|
+
max_batch_size,
|
|
140
|
+
max_queue_size,
|
|
141
|
+
capture_parameter_names,
|
|
142
|
+
version,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
with _lock:
|
|
146
|
+
if _reporter is not None and _active == (settings, on_diagnostic):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
previous = _reporter
|
|
150
|
+
_stop_collecting()
|
|
151
|
+
if previous is not None:
|
|
152
|
+
previous.stop()
|
|
153
|
+
|
|
154
|
+
verbose = debug if debug is not None else on_diagnostic is not None
|
|
155
|
+
|
|
156
|
+
# No key is a normal state, not a mistake. Saying so on every start
|
|
157
|
+
# would be noise.
|
|
158
|
+
if key is None:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if url is None:
|
|
162
|
+
# Said unasked, as a refused key is: without it the data goes
|
|
163
|
+
# nowhere and nothing tells anyone.
|
|
164
|
+
if not _said_no_endpoint:
|
|
165
|
+
_said_no_endpoint = True
|
|
166
|
+
try:
|
|
167
|
+
if on_diagnostic is not None:
|
|
168
|
+
on_diagnostic(NO_ENDPOINT)
|
|
169
|
+
else:
|
|
170
|
+
print(NO_ENDPOINT, file=sys.stderr)
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
options: dict[str, Any] = {}
|
|
176
|
+
interval = _positive("flush_interval", flush_interval, verbose, integer=False)
|
|
177
|
+
if interval is not None:
|
|
178
|
+
options["flush_interval"] = float(interval)
|
|
179
|
+
batch = _positive("max_batch_size", max_batch_size, verbose, integer=True)
|
|
180
|
+
if batch is not None:
|
|
181
|
+
# The API takes at most 1,000 events per request.
|
|
182
|
+
options["max_batch_size"] = min(batch, 1_000)
|
|
183
|
+
queue = _positive("max_queue_size", max_queue_size, verbose, integer=True)
|
|
184
|
+
if queue is not None:
|
|
185
|
+
options["max_queue_size"] = queue
|
|
186
|
+
|
|
187
|
+
reporter = EventReporter(
|
|
188
|
+
endpoint=url,
|
|
189
|
+
api_key=key,
|
|
190
|
+
debug=verbose,
|
|
191
|
+
on_diagnostic=on_diagnostic,
|
|
192
|
+
**options,
|
|
193
|
+
)
|
|
194
|
+
_reporter = reporter
|
|
195
|
+
_active = (settings, on_diagnostic)
|
|
196
|
+
set_capture_parameter_names(capture_parameter_names)
|
|
197
|
+
set_server_version(version)
|
|
198
|
+
set_event_sink(reporter.record)
|
|
199
|
+
|
|
200
|
+
if flush_on_exit:
|
|
201
|
+
_install_exit_hook()
|
|
202
|
+
_install_fork_hook()
|
|
203
|
+
|
|
204
|
+
# In the background: startup does not wait for the network.
|
|
205
|
+
reporter.start()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _stop_collecting() -> None:
|
|
209
|
+
global _reporter, _active
|
|
210
|
+
_reporter = None
|
|
211
|
+
_active = None
|
|
212
|
+
set_event_sink(None)
|
|
213
|
+
set_capture_parameter_names(False)
|
|
214
|
+
set_server_version(None)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def shutdown() -> None:
|
|
218
|
+
"""Stops collecting and makes a final attempt to deliver what is queued.
|
|
219
|
+
|
|
220
|
+
Worth calling from a server's own shutdown path. Without it, and with
|
|
221
|
+
`flush_on_exit` off, the last partly filled batch dies with the process.
|
|
222
|
+
Blocks for as long as that delivery takes, at most a few seconds.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
with _lock:
|
|
226
|
+
previous = _reporter
|
|
227
|
+
_stop_collecting()
|
|
228
|
+
if previous is not None:
|
|
229
|
+
previous.stop()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def is_collecting() -> bool:
|
|
235
|
+
"""Whether the SDK is currently recording tool calls."""
|
|
236
|
+
return _reporter is not None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _on_exit() -> None:
|
|
240
|
+
# Only when this configuration asked for it: a later configure() may have
|
|
241
|
+
# turned it off, and atexit offers no way to ask which one registered.
|
|
242
|
+
active = _active
|
|
243
|
+
if active is not None and active[0][3]:
|
|
244
|
+
shutdown()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _install_exit_hook() -> None:
|
|
248
|
+
"""Arranges one last delivery as the interpreter exits.
|
|
249
|
+
|
|
250
|
+
atexit runs once the program's own code is done, including on the normal
|
|
251
|
+
end of a stdio server when its client leaves. It does not intercept
|
|
252
|
+
signals and does not change how or when the server exits.
|
|
253
|
+
"""
|
|
254
|
+
global _exit_hook_installed
|
|
255
|
+
if not _exit_hook_installed:
|
|
256
|
+
atexit.register(_on_exit)
|
|
257
|
+
_exit_hook_installed = True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _install_fork_hook() -> None:
|
|
261
|
+
"""Keeps a forked worker (gunicorn, multiprocessing) collecting.
|
|
262
|
+
|
|
263
|
+
A child inherits no threads. The reporter notices the new process on its
|
|
264
|
+
own when the next event arrives; this only makes sure the configuration
|
|
265
|
+
lock was not inherited in a held state.
|
|
266
|
+
"""
|
|
267
|
+
global _fork_hook_installed
|
|
268
|
+
if _fork_hook_installed or not hasattr(os, "register_at_fork"):
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
def reset_lock() -> None:
|
|
272
|
+
global _lock
|
|
273
|
+
_lock = threading.RLock()
|
|
274
|
+
|
|
275
|
+
os.register_at_fork(after_in_child=reset_lock)
|
|
276
|
+
_fork_hook_installed = True
|
mcpspan/_failure.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
MAX_EXCEPTION_MESSAGE_LENGTH = 500
|
|
7
|
+
"""Longest message kept from a raised exception.
|
|
8
|
+
|
|
9
|
+
Exception messages are written by developers for developers, so they are
|
|
10
|
+
mostly safe to keep and mostly worth reading in full.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
MAX_RESULT_MESSAGE_LENGTH = 200
|
|
14
|
+
"""Longest message kept from a result marked as an error.
|
|
15
|
+
|
|
16
|
+
Shorter than the exception limit on purpose. This text was written for a
|
|
17
|
+
language model to read, so it is far more likely than an exception message to
|
|
18
|
+
quote back whatever the user asked about.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
MAX_NAME_LENGTH = 200
|
|
22
|
+
"""Longest text the ingest API takes in a name-like field: a tool, an error
|
|
23
|
+
type, a client, a parameter.
|
|
24
|
+
|
|
25
|
+
The API refuses a whole batch when any one field is over its limit, so an
|
|
26
|
+
over-long value would take every other event in its batch down with it. These
|
|
27
|
+
values come from outside the developer's control - a client names itself, an
|
|
28
|
+
exception names its own class - so they are cut here rather than trusted.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def truncate(text: str, limit: int) -> str:
|
|
33
|
+
"""Cuts text to a limit, leaving a visible sign that something was removed."""
|
|
34
|
+
return text if len(text) <= limit else f"{text[: limit - 3]}..."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_error(error: BaseException) -> str:
|
|
38
|
+
"""Renders an exception as one readable line, for diagnostics."""
|
|
39
|
+
return f"{type(error).__name__}: {error}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _field(value: Any, *names: str) -> Any:
|
|
43
|
+
"""Reads the first of several spellings of a field, from a model or a dict.
|
|
44
|
+
|
|
45
|
+
The official MCP SDK names result fields in camel case in v1 (`isError`)
|
|
46
|
+
and in snake case in v2 (`is_error`), and a tool may also return a plain
|
|
47
|
+
dict in the wire's own spelling. Callers list the v2 spelling first: v2
|
|
48
|
+
keeps the old name as a deprecated alias whose every read warns, and those
|
|
49
|
+
warnings would land in the developer's logs.
|
|
50
|
+
"""
|
|
51
|
+
for name in names:
|
|
52
|
+
if isinstance(value, Mapping):
|
|
53
|
+
if name in value:
|
|
54
|
+
return value[name]
|
|
55
|
+
elif hasattr(value, name):
|
|
56
|
+
return getattr(value, name)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_error_result(result: Any) -> bool:
|
|
62
|
+
"""Whether a tool reported its own failure through the result.
|
|
63
|
+
|
|
64
|
+
MCP asks tools to answer with `isError` rather than raising, so that the
|
|
65
|
+
model can see what went wrong and react. A wrapper that only watched for
|
|
66
|
+
exceptions would record a correctly written server as never failing.
|
|
67
|
+
"""
|
|
68
|
+
return _field(result, "is_error", "isError") is True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_INTERIM_TYPES = ("InputRequiredResult", "InputRequiredToolResult")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_input_required(result: Any) -> bool:
|
|
75
|
+
"""A result the 2026-07-28 protocol calls interim: the tool needs more input first."""
|
|
76
|
+
# The official SDK's result type, and FastMCP's subclass of its own.
|
|
77
|
+
if any(cls.__name__ in _INTERIM_TYPES for cls in type(result).__mro__):
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
return bool(_field(result, "result_type", "resultType") == "input_required")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def describe_error_result(result: Any) -> str | None:
|
|
84
|
+
"""Pulls a short description out of a tool result that reported an error.
|
|
85
|
+
|
|
86
|
+
Reads only text blocks. Images and binary attachments carry no message
|
|
87
|
+
worth storing, and copying them anywhere would be indefensible.
|
|
88
|
+
"""
|
|
89
|
+
content = _field(result, "content")
|
|
90
|
+
if not isinstance(content, (list, tuple)):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
texts = [
|
|
94
|
+
text
|
|
95
|
+
for block in content
|
|
96
|
+
if _field(block, "type") == "text" and isinstance(text := _field(block, "text"), str)
|
|
97
|
+
]
|
|
98
|
+
joined = " ".join(texts).strip()
|
|
99
|
+
|
|
100
|
+
return truncate(joined, MAX_RESULT_MESSAGE_LENGTH) if joined else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def describe_exception(error: BaseException) -> tuple[str, str | None]:
|
|
104
|
+
"""The class name and message of a raised exception, cut to their limits."""
|
|
105
|
+
message = str(error)
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
truncate(type(error).__name__, MAX_NAME_LENGTH),
|
|
109
|
+
truncate(message, MAX_EXCEPTION_MESSAGE_LENGTH) if message else None,
|
|
110
|
+
)
|