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 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
+ )