mcpspan 0.1.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.
- mcpspan-0.1.0/.gitignore +59 -0
- mcpspan-0.1.0/LICENSE +21 -0
- mcpspan-0.1.0/PKG-INFO +309 -0
- mcpspan-0.1.0/README.md +287 -0
- mcpspan-0.1.0/pyproject.toml +94 -0
- mcpspan-0.1.0/src/mcpspan/__init__.py +34 -0
- mcpspan-0.1.0/src/mcpspan/_call.py +49 -0
- mcpspan-0.1.0/src/mcpspan/_client.py +148 -0
- mcpspan-0.1.0/src/mcpspan/_config.py +276 -0
- mcpspan-0.1.0/src/mcpspan/_failure.py +110 -0
- mcpspan-0.1.0/src/mcpspan/_fastmcp.py +297 -0
- mcpspan-0.1.0/src/mcpspan/_instrument.py +82 -0
- mcpspan-0.1.0/src/mcpspan/_marks.py +51 -0
- mcpspan-0.1.0/src/mcpspan/_official.py +240 -0
- mcpspan-0.1.0/src/mcpspan/_parameters.py +58 -0
- mcpspan-0.1.0/src/mcpspan/_primitives.py +363 -0
- mcpspan-0.1.0/src/mcpspan/_queue.py +63 -0
- mcpspan-0.1.0/src/mcpspan/_reporter.py +290 -0
- mcpspan-0.1.0/src/mcpspan/_session.py +89 -0
- mcpspan-0.1.0/src/mcpspan/_track.py +390 -0
- mcpspan-0.1.0/src/mcpspan/_transport.py +152 -0
- mcpspan-0.1.0/src/mcpspan/_types.py +54 -0
- mcpspan-0.1.0/src/mcpspan/_version.py +6 -0
- mcpspan-0.1.0/src/mcpspan/py.typed +0 -0
- mcpspan-0.1.0/tests/__init__.py +0 -0
- mcpspan-0.1.0/tests/conftest.py +28 -0
- mcpspan-0.1.0/tests/test_client.py +82 -0
- mcpspan-0.1.0/tests/test_config.py +142 -0
- mcpspan-0.1.0/tests/test_failure.py +70 -0
- mcpspan-0.1.0/tests/test_fastmcp.py +265 -0
- mcpspan-0.1.0/tests/test_official.py +411 -0
- mcpspan-0.1.0/tests/test_parameters.py +45 -0
- mcpspan-0.1.0/tests/test_queue.py +45 -0
- mcpspan-0.1.0/tests/test_readme.py +48 -0
- mcpspan-0.1.0/tests/test_reporter.py +222 -0
- mcpspan-0.1.0/tests/test_session.py +48 -0
- mcpspan-0.1.0/tests/test_track.py +257 -0
- mcpspan-0.1.0/tests/test_transport.py +127 -0
- mcpspan-0.1.0/uv.lock +2489 -0
mcpspan-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
next-env.d.ts
|
|
5
|
+
*.tsbuildinfo
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
!.env.example
|
|
9
|
+
*.log
|
|
10
|
+
|
|
11
|
+
# Database dumps. Somebody's data, and often a lot of it.
|
|
12
|
+
backups/
|
|
13
|
+
.DS_Store
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Python
|
|
17
|
+
.venv/
|
|
18
|
+
__pycache__/
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
|
|
23
|
+
# Go conformance adapters, built before the suite runs
|
|
24
|
+
conformance/adapters/go-*/adapter
|
|
25
|
+
|
|
26
|
+
# .NET build output
|
|
27
|
+
bin/
|
|
28
|
+
obj/
|
|
29
|
+
conformance/adapters/dotnet/out/
|
|
30
|
+
|
|
31
|
+
# Throwaway twins for comparing SDKs against the dashboard
|
|
32
|
+
conformance/.criterion/
|
|
33
|
+
|
|
34
|
+
# Gradle
|
|
35
|
+
.gradle/
|
|
36
|
+
packages/mcpspan-jvm/**/build/
|
|
37
|
+
conformance/adapters/java/build/
|
|
38
|
+
conformance/adapters/kotlin/build/
|
|
39
|
+
|
|
40
|
+
# Cargo
|
|
41
|
+
packages/mcpspan-rust/target/
|
|
42
|
+
conformance/adapters/rust/target/
|
|
43
|
+
|
|
44
|
+
# Bundler
|
|
45
|
+
packages/mcpspan-ruby/Gemfile.lock
|
|
46
|
+
packages/mcpspan-ruby/vendor/
|
|
47
|
+
packages/mcpspan-ruby/*.gem
|
|
48
|
+
conformance/adapters/ruby/Gemfile.lock
|
|
49
|
+
conformance/adapters/ruby/vendor/
|
|
50
|
+
conformance/adapters/ruby/.bundle/
|
|
51
|
+
|
|
52
|
+
# Composer
|
|
53
|
+
packages/mcpspan-php/vendor/
|
|
54
|
+
packages/mcpspan-php/composer.lock
|
|
55
|
+
packages/mcpspan-php/.php-cs-fixer.cache
|
|
56
|
+
packages/mcpspan-php/.phpunit.cache/
|
|
57
|
+
conformance/adapters/php-*/vendor/
|
|
58
|
+
conformance/adapters/php-*/composer.lock
|
|
59
|
+
packages/mcpspan-php/.phpunit.result.cache
|
mcpspan-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kacper Zatoń
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mcpspan-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcpspan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-hosted analytics for MCP servers: which tools, resources and prompts get used, by which client, how fast, and why they fail.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mcpspan/mcpspan
|
|
6
|
+
Project-URL: Documentation, https://github.com/mcpspan/mcpspan/tree/main/packages/mcpspan-python#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/mcpspan/mcpspan
|
|
8
|
+
Project-URL: Issues, https://github.com/mcpspan/mcpspan/issues
|
|
9
|
+
Author: Kacper Zatoń
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,analytics,mcp,mcp-server,metrics,model-context-protocol,monitoring,observability,telemetry,tool-calls
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# mcpspan for Python
|
|
24
|
+
|
|
25
|
+
Analytics for MCP servers. Find out which of your tools get called, by which
|
|
26
|
+
client, how long they take, and which ones fail.
|
|
27
|
+
|
|
28
|
+
Your own logs tell you a tool ran. This tells you whether it was Claude,
|
|
29
|
+
Cursor, or something you have not heard of, how that call compares to the
|
|
30
|
+
other nine hundred, and whether the failures are your tool breaking or your
|
|
31
|
+
tool politely saying no.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install mcpspan
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Needs Python 3.10 or newer. No dependencies. Works with the official MCP SDK,
|
|
40
|
+
`mcp` 1.30+ (`FastMCP`) and 2.2+ (`MCPServer`), and with FastMCP 4+ (the
|
|
41
|
+
`fastmcp` package).
|
|
42
|
+
|
|
43
|
+
## Use
|
|
44
|
+
|
|
45
|
+
One line, anywhere before the server starts:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
import mcpspan
|
|
51
|
+
from mcp.server.fastmcp import FastMCP
|
|
52
|
+
|
|
53
|
+
mcp = FastMCP("flights")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def search_flights(destination: str) -> str:
|
|
58
|
+
return f"Found 3 flights to {destination}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
mcpspan.instrument(
|
|
62
|
+
mcp,
|
|
63
|
+
api_key=os.environ.get("MCPSPAN_API_KEY"),
|
|
64
|
+
endpoint="http://localhost:6271", # your mcpspan installation
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
mcp.run()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every tool on the server is measured, whether it was registered before that
|
|
72
|
+
line or after. Nothing about how you write tools changes: the schema the
|
|
73
|
+
server builds from your function is untouched, and your tool returns and
|
|
74
|
+
raises exactly what it did before.
|
|
75
|
+
|
|
76
|
+
The same line works on v2 of the MCP SDK and on FastMCP:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from mcp.server.mcpserver import MCPServer
|
|
80
|
+
|
|
81
|
+
mcp = MCPServer("flights")
|
|
82
|
+
mcpspan.instrument(mcp)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from fastmcp import FastMCP
|
|
87
|
+
|
|
88
|
+
mcp = FastMCP("flights")
|
|
89
|
+
mcpspan.instrument(mcp)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
With no `api_key` given, it is read from `MCPSPAN_API_KEY`.
|
|
93
|
+
|
|
94
|
+
### Sessions and clients
|
|
95
|
+
|
|
96
|
+
Calls are grouped into sessions when there is a connection to group them by:
|
|
97
|
+
a stdio process, or an HTTP transport that hands out session IDs. A stateless
|
|
98
|
+
HTTP endpoint, and every endpoint on the 2026-07-28 protocol, which dropped
|
|
99
|
+
sessions, records calls without one.
|
|
100
|
+
|
|
101
|
+
The client is read from the call itself on 2026-07-28, where each request
|
|
102
|
+
names its client, and from the handshake on 2025-11-25. A stateless
|
|
103
|
+
2025-11-25 HTTP endpoint has no handshake to read, so its calls are recorded
|
|
104
|
+
with an unknown client rather than a guessed one.
|
|
105
|
+
|
|
106
|
+
A tool that asks the client for more before it can finish is one call however
|
|
107
|
+
many round trips that takes. The interim answer asking for input is not
|
|
108
|
+
counted; the one that ends the call is.
|
|
109
|
+
|
|
110
|
+
### Without a key
|
|
111
|
+
|
|
112
|
+
If there is no key, nothing is collected and nothing is sent, and no thread is
|
|
113
|
+
started. Wrapped tools return before reading the clock. That makes it safe to
|
|
114
|
+
leave in place in tests, in CI, and in a fork somebody is only reading.
|
|
115
|
+
|
|
116
|
+
### One tool at a time
|
|
117
|
+
|
|
118
|
+
If your server is not one of the above, or you want to pick tools by hand:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import mcpspan
|
|
122
|
+
|
|
123
|
+
mcpspan.configure() # reads MCPSPAN_API_KEY and MCPSPAN_ENDPOINT
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcpspan.track("search_flights")
|
|
127
|
+
async def search_flights(destination: str) -> str:
|
|
128
|
+
return f"Flights to {destination}"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`track` keeps the function's signature, name and docstring, so a server
|
|
132
|
+
builds the same schema from it. A function tracked by hand and then
|
|
133
|
+
registered on an instrumented server is counted once.
|
|
134
|
+
|
|
135
|
+
### Leaving a tool out
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
@mcp.tool()
|
|
139
|
+
@mcpspan.exclude
|
|
140
|
+
def health_check() -> str:
|
|
141
|
+
return "ok"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For tools called by machinery rather than by an agent. A health check polled
|
|
145
|
+
every few seconds outnumbers everything a person does and drags the whole
|
|
146
|
+
server's error rate and response time towards its own. Put `exclude` below
|
|
147
|
+
the server's decorator, so the server registers the marked function.
|
|
148
|
+
|
|
149
|
+
It takes no tool name on purpose: a name written twice can drift during a
|
|
150
|
+
rename, and the exclusion would quietly stop applying.
|
|
151
|
+
|
|
152
|
+
### Resources and prompts
|
|
153
|
+
|
|
154
|
+
Reads of your resources and gets of your prompts are measured too, with
|
|
155
|
+
nothing to add: each is one event, in the same session and from the same
|
|
156
|
+
client as the tool calls around it, and the dashboard shows them in a card of
|
|
157
|
+
their own and in each session's timeline. Listings are not recorded.
|
|
158
|
+
|
|
159
|
+
A resource at a fixed address is named by that address. One read through a
|
|
160
|
+
template is named by the template, `trips://{id}`, never by the address the
|
|
161
|
+
client asked for, which can carry a user's data; the template's variables are
|
|
162
|
+
its parameters, by name only. A read of an address the server has nothing for
|
|
163
|
+
is named by its scheme alone, `db://`. A prompt is named by its name, and its
|
|
164
|
+
arguments are its parameters, as a tool's are.
|
|
165
|
+
|
|
166
|
+
On the official MCP SDK and on FastMCP alike.
|
|
167
|
+
|
|
168
|
+
### Versions
|
|
169
|
+
|
|
170
|
+
Every call carries the version of the server that answered it, so the
|
|
171
|
+
dashboard marks where each release began and compares it with the one before.
|
|
172
|
+
There is nothing to add: it is the version the server gives itself,
|
|
173
|
+
`MCPServer("flights", version="1.4.0")` on v2 of the MCP SDK or
|
|
174
|
+
`FastMCP("flights", version="1.4.0")`. v1's `FastMCP` takes no version, so
|
|
175
|
+
there, or to record a commit or a deploy instead, set `server_version` (or
|
|
176
|
+
`MCPSPAN_SERVER_VERSION`). The client's version is recorded beside its name.
|
|
177
|
+
|
|
178
|
+
### Shutting down
|
|
179
|
+
|
|
180
|
+
Queued events are delivered as the interpreter exits, so most servers need
|
|
181
|
+
nothing here. If yours has its own shutdown path and you want to be explicit:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
mcpspan.shutdown()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
It blocks for as long as that last delivery takes, a few seconds at most. A
|
|
188
|
+
process killed outright (`kill -9`, a container stopped without notice) runs
|
|
189
|
+
nothing after that, and the last few seconds of calls go with it.
|
|
190
|
+
|
|
191
|
+
Forked workers, as under gunicorn, keep collecting: each starts its own
|
|
192
|
+
delivery on its first call.
|
|
193
|
+
|
|
194
|
+
## Two kinds of failure
|
|
195
|
+
|
|
196
|
+
MCP asks tools to report their own errors inside the result, with `isError`
|
|
197
|
+
set, so the model can see what went wrong. A raised exception is the deviation
|
|
198
|
+
from that, and usually means the tool broke.
|
|
199
|
+
|
|
200
|
+
Both are recorded, and each event says which happened, with the exception's
|
|
201
|
+
class name for the second: "no flights found" is a tool working as written,
|
|
202
|
+
while a `KeyError` is something to fix. On FastMCP, a `ToolError` you raise
|
|
203
|
+
on purpose is recorded as `ToolError`; anything else your tool raises is
|
|
204
|
+
recorded as itself, not as the error FastMCP wraps it in.
|
|
205
|
+
|
|
206
|
+
### And two that never reach your tool
|
|
207
|
+
|
|
208
|
+
Calls the server refuses on its own are recorded too: arguments that fail
|
|
209
|
+
validation, and names it has no tool for. Both reach the model as error
|
|
210
|
+
results. Bad arguments are the commonest way an agent fails, so leaving them
|
|
211
|
+
out would make a server look healthier than it is to the agents using it.
|
|
212
|
+
|
|
213
|
+
A refused call carries no message, because the validation text can quote back
|
|
214
|
+
what the agent sent. With `capture_parameter_names` on, it carries the names
|
|
215
|
+
and types of the arguments instead, which is what shows the agent wrote
|
|
216
|
+
`dest` where the schema says `destination`. Tools passed through `exclude`
|
|
217
|
+
stay out of this as well.
|
|
218
|
+
|
|
219
|
+
## Privacy
|
|
220
|
+
|
|
221
|
+
**Parameter values never leave your process.** Not by default, not in any
|
|
222
|
+
mode, not in debug.
|
|
223
|
+
|
|
224
|
+
What is collected: the tool name, how long it took, whether it succeeded, the
|
|
225
|
+
error type and a truncated message when it did not, which client called, and
|
|
226
|
+
the SDK version. For a resource or a prompt, the same, under the name it was
|
|
227
|
+
registered with: never the address a client read, only its template or, for
|
|
228
|
+
an address the server does not have, its scheme.
|
|
229
|
+
|
|
230
|
+
Optionally, parameter *names and types*:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
mcpspan.instrument(mcp, capture_parameter_names=True)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
That records `{"destination": "string", "passengers": "number"}`, in JSON's
|
|
237
|
+
vocabulary, as the client sent them. Knowing `search_flights` is always
|
|
238
|
+
called with `destination` and never with `departure_date` tells you your
|
|
239
|
+
tool description is not landing. Knowing which destination tells you nothing
|
|
240
|
+
you needed, and puts your users' data somewhere it does not belong.
|
|
241
|
+
|
|
242
|
+
## Self-hosting
|
|
243
|
+
|
|
244
|
+
Point it at your own installation:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
mcpspan.instrument(mcp, endpoint="https://mcpspan.example.com")
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Or set `MCPSPAN_ENDPOINT`. There is no default: events go only where you point
|
|
251
|
+
them. With a key and no endpoint, nothing is collected, and the SDK says so
|
|
252
|
+
once on standard error.
|
|
253
|
+
|
|
254
|
+
When it starts with a key, the SDK sends one empty batch to say it is there.
|
|
255
|
+
That is how the dashboard's Status page tells a server nobody has used yet
|
|
256
|
+
from one pointed at the wrong address, and how a wrong key is reported when
|
|
257
|
+
your server starts rather than at its first tool call.
|
|
258
|
+
|
|
259
|
+
## It will not break your server
|
|
260
|
+
|
|
261
|
+
- Delivery happens on a background thread of its own, whether your server is
|
|
262
|
+
synchronous, on asyncio or on trio. A tool call returns without waiting on
|
|
263
|
+
the network, and the thread never keeps a process alive.
|
|
264
|
+
- A failure to send is never raised into your code. Retryable failures wait
|
|
265
|
+
and try again with a widening gap; a refused key switches collection off
|
|
266
|
+
and says so once on standard error.
|
|
267
|
+
- The queue is bounded. An unreachable endpoint cannot grow it until your
|
|
268
|
+
process runs out of memory.
|
|
269
|
+
- Nothing is ever written to standard output, which carries the MCP protocol
|
|
270
|
+
on a stdio server. Diagnostics go to standard error.
|
|
271
|
+
- `configure`, `instrument` and `shutdown` never raise. A mistyped option
|
|
272
|
+
falls back to its default rather than stopping your server from starting.
|
|
273
|
+
|
|
274
|
+
## Options
|
|
275
|
+
|
|
276
|
+
Given to `instrument` or `configure`.
|
|
277
|
+
|
|
278
|
+
| Option | Default | What it does |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| `api_key` | `MCPSPAN_API_KEY` | Identifies your server. Without it, nothing is collected. |
|
|
281
|
+
| `endpoint` | `MCPSPAN_ENDPOINT`; none | Your mcpspan installation. Nothing is collected without it. |
|
|
282
|
+
| `capture_parameter_names` | `False` | Records parameter names and types, never values. |
|
|
283
|
+
| `server_version` | `MCPSPAN_SERVER_VERSION`, then the server's own | The version to record calls under: a release, a tag, a commit. |
|
|
284
|
+
| `debug` | `False` | Writes delivery diagnostics to standard error. |
|
|
285
|
+
| `on_diagnostic` | - | Receives diagnostics instead. Implies `debug`. |
|
|
286
|
+
| `flush_on_exit` | `True` | Delivers what is queued as the interpreter exits. |
|
|
287
|
+
| `flush_interval` | `5.0` | Seconds a partly filled batch waits. |
|
|
288
|
+
| `max_batch_size` | `100` | Events per request. Reaching it sends early. |
|
|
289
|
+
| `max_queue_size` | `10000` | Events held while delivery is failing. |
|
|
290
|
+
|
|
291
|
+
Calling `configure` or `instrument` again with the same settings changes
|
|
292
|
+
nothing, so a server built per request can pass them every time.
|
|
293
|
+
|
|
294
|
+
## Developing
|
|
295
|
+
|
|
296
|
+
```sh
|
|
297
|
+
uv sync --group mcp2 # or mcp1, or fastmcp: the MCP SDK the tests run against
|
|
298
|
+
uv run --group mcp2 pytest
|
|
299
|
+
uv run --group mcp2 mypy
|
|
300
|
+
uv run ruff check && uv run ruff format --check
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The SDK follows [the contract every mcpspan SDK
|
|
304
|
+
follows](../../docs/sdk-contract.md), checked by the suite in
|
|
305
|
+
[`conformance/`](../../conformance/README.md).
|
|
306
|
+
|
|
307
|
+
## Licence
|
|
308
|
+
|
|
309
|
+
MIT.
|
mcpspan-0.1.0/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# mcpspan for Python
|
|
2
|
+
|
|
3
|
+
Analytics for MCP servers. Find out which of your tools get called, by which
|
|
4
|
+
client, how long they take, and which ones fail.
|
|
5
|
+
|
|
6
|
+
Your own logs tell you a tool ran. This tells you whether it was Claude,
|
|
7
|
+
Cursor, or something you have not heard of, how that call compares to the
|
|
8
|
+
other nine hundred, and whether the failures are your tool breaking or your
|
|
9
|
+
tool politely saying no.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install mcpspan
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Needs Python 3.10 or newer. No dependencies. Works with the official MCP SDK,
|
|
18
|
+
`mcp` 1.30+ (`FastMCP`) and 2.2+ (`MCPServer`), and with FastMCP 4+ (the
|
|
19
|
+
`fastmcp` package).
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
One line, anywhere before the server starts:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
import mcpspan
|
|
29
|
+
from mcp.server.fastmcp import FastMCP
|
|
30
|
+
|
|
31
|
+
mcp = FastMCP("flights")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
def search_flights(destination: str) -> str:
|
|
36
|
+
return f"Found 3 flights to {destination}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
mcpspan.instrument(
|
|
40
|
+
mcp,
|
|
41
|
+
api_key=os.environ.get("MCPSPAN_API_KEY"),
|
|
42
|
+
endpoint="http://localhost:6271", # your mcpspan installation
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
mcp.run()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Every tool on the server is measured, whether it was registered before that
|
|
50
|
+
line or after. Nothing about how you write tools changes: the schema the
|
|
51
|
+
server builds from your function is untouched, and your tool returns and
|
|
52
|
+
raises exactly what it did before.
|
|
53
|
+
|
|
54
|
+
The same line works on v2 of the MCP SDK and on FastMCP:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from mcp.server.mcpserver import MCPServer
|
|
58
|
+
|
|
59
|
+
mcp = MCPServer("flights")
|
|
60
|
+
mcpspan.instrument(mcp)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from fastmcp import FastMCP
|
|
65
|
+
|
|
66
|
+
mcp = FastMCP("flights")
|
|
67
|
+
mcpspan.instrument(mcp)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
With no `api_key` given, it is read from `MCPSPAN_API_KEY`.
|
|
71
|
+
|
|
72
|
+
### Sessions and clients
|
|
73
|
+
|
|
74
|
+
Calls are grouped into sessions when there is a connection to group them by:
|
|
75
|
+
a stdio process, or an HTTP transport that hands out session IDs. A stateless
|
|
76
|
+
HTTP endpoint, and every endpoint on the 2026-07-28 protocol, which dropped
|
|
77
|
+
sessions, records calls without one.
|
|
78
|
+
|
|
79
|
+
The client is read from the call itself on 2026-07-28, where each request
|
|
80
|
+
names its client, and from the handshake on 2025-11-25. A stateless
|
|
81
|
+
2025-11-25 HTTP endpoint has no handshake to read, so its calls are recorded
|
|
82
|
+
with an unknown client rather than a guessed one.
|
|
83
|
+
|
|
84
|
+
A tool that asks the client for more before it can finish is one call however
|
|
85
|
+
many round trips that takes. The interim answer asking for input is not
|
|
86
|
+
counted; the one that ends the call is.
|
|
87
|
+
|
|
88
|
+
### Without a key
|
|
89
|
+
|
|
90
|
+
If there is no key, nothing is collected and nothing is sent, and no thread is
|
|
91
|
+
started. Wrapped tools return before reading the clock. That makes it safe to
|
|
92
|
+
leave in place in tests, in CI, and in a fork somebody is only reading.
|
|
93
|
+
|
|
94
|
+
### One tool at a time
|
|
95
|
+
|
|
96
|
+
If your server is not one of the above, or you want to pick tools by hand:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import mcpspan
|
|
100
|
+
|
|
101
|
+
mcpspan.configure() # reads MCPSPAN_API_KEY and MCPSPAN_ENDPOINT
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcpspan.track("search_flights")
|
|
105
|
+
async def search_flights(destination: str) -> str:
|
|
106
|
+
return f"Flights to {destination}"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`track` keeps the function's signature, name and docstring, so a server
|
|
110
|
+
builds the same schema from it. A function tracked by hand and then
|
|
111
|
+
registered on an instrumented server is counted once.
|
|
112
|
+
|
|
113
|
+
### Leaving a tool out
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@mcp.tool()
|
|
117
|
+
@mcpspan.exclude
|
|
118
|
+
def health_check() -> str:
|
|
119
|
+
return "ok"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
For tools called by machinery rather than by an agent. A health check polled
|
|
123
|
+
every few seconds outnumbers everything a person does and drags the whole
|
|
124
|
+
server's error rate and response time towards its own. Put `exclude` below
|
|
125
|
+
the server's decorator, so the server registers the marked function.
|
|
126
|
+
|
|
127
|
+
It takes no tool name on purpose: a name written twice can drift during a
|
|
128
|
+
rename, and the exclusion would quietly stop applying.
|
|
129
|
+
|
|
130
|
+
### Resources and prompts
|
|
131
|
+
|
|
132
|
+
Reads of your resources and gets of your prompts are measured too, with
|
|
133
|
+
nothing to add: each is one event, in the same session and from the same
|
|
134
|
+
client as the tool calls around it, and the dashboard shows them in a card of
|
|
135
|
+
their own and in each session's timeline. Listings are not recorded.
|
|
136
|
+
|
|
137
|
+
A resource at a fixed address is named by that address. One read through a
|
|
138
|
+
template is named by the template, `trips://{id}`, never by the address the
|
|
139
|
+
client asked for, which can carry a user's data; the template's variables are
|
|
140
|
+
its parameters, by name only. A read of an address the server has nothing for
|
|
141
|
+
is named by its scheme alone, `db://`. A prompt is named by its name, and its
|
|
142
|
+
arguments are its parameters, as a tool's are.
|
|
143
|
+
|
|
144
|
+
On the official MCP SDK and on FastMCP alike.
|
|
145
|
+
|
|
146
|
+
### Versions
|
|
147
|
+
|
|
148
|
+
Every call carries the version of the server that answered it, so the
|
|
149
|
+
dashboard marks where each release began and compares it with the one before.
|
|
150
|
+
There is nothing to add: it is the version the server gives itself,
|
|
151
|
+
`MCPServer("flights", version="1.4.0")` on v2 of the MCP SDK or
|
|
152
|
+
`FastMCP("flights", version="1.4.0")`. v1's `FastMCP` takes no version, so
|
|
153
|
+
there, or to record a commit or a deploy instead, set `server_version` (or
|
|
154
|
+
`MCPSPAN_SERVER_VERSION`). The client's version is recorded beside its name.
|
|
155
|
+
|
|
156
|
+
### Shutting down
|
|
157
|
+
|
|
158
|
+
Queued events are delivered as the interpreter exits, so most servers need
|
|
159
|
+
nothing here. If yours has its own shutdown path and you want to be explicit:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
mcpspan.shutdown()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
It blocks for as long as that last delivery takes, a few seconds at most. A
|
|
166
|
+
process killed outright (`kill -9`, a container stopped without notice) runs
|
|
167
|
+
nothing after that, and the last few seconds of calls go with it.
|
|
168
|
+
|
|
169
|
+
Forked workers, as under gunicorn, keep collecting: each starts its own
|
|
170
|
+
delivery on its first call.
|
|
171
|
+
|
|
172
|
+
## Two kinds of failure
|
|
173
|
+
|
|
174
|
+
MCP asks tools to report their own errors inside the result, with `isError`
|
|
175
|
+
set, so the model can see what went wrong. A raised exception is the deviation
|
|
176
|
+
from that, and usually means the tool broke.
|
|
177
|
+
|
|
178
|
+
Both are recorded, and each event says which happened, with the exception's
|
|
179
|
+
class name for the second: "no flights found" is a tool working as written,
|
|
180
|
+
while a `KeyError` is something to fix. On FastMCP, a `ToolError` you raise
|
|
181
|
+
on purpose is recorded as `ToolError`; anything else your tool raises is
|
|
182
|
+
recorded as itself, not as the error FastMCP wraps it in.
|
|
183
|
+
|
|
184
|
+
### And two that never reach your tool
|
|
185
|
+
|
|
186
|
+
Calls the server refuses on its own are recorded too: arguments that fail
|
|
187
|
+
validation, and names it has no tool for. Both reach the model as error
|
|
188
|
+
results. Bad arguments are the commonest way an agent fails, so leaving them
|
|
189
|
+
out would make a server look healthier than it is to the agents using it.
|
|
190
|
+
|
|
191
|
+
A refused call carries no message, because the validation text can quote back
|
|
192
|
+
what the agent sent. With `capture_parameter_names` on, it carries the names
|
|
193
|
+
and types of the arguments instead, which is what shows the agent wrote
|
|
194
|
+
`dest` where the schema says `destination`. Tools passed through `exclude`
|
|
195
|
+
stay out of this as well.
|
|
196
|
+
|
|
197
|
+
## Privacy
|
|
198
|
+
|
|
199
|
+
**Parameter values never leave your process.** Not by default, not in any
|
|
200
|
+
mode, not in debug.
|
|
201
|
+
|
|
202
|
+
What is collected: the tool name, how long it took, whether it succeeded, the
|
|
203
|
+
error type and a truncated message when it did not, which client called, and
|
|
204
|
+
the SDK version. For a resource or a prompt, the same, under the name it was
|
|
205
|
+
registered with: never the address a client read, only its template or, for
|
|
206
|
+
an address the server does not have, its scheme.
|
|
207
|
+
|
|
208
|
+
Optionally, parameter *names and types*:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
mcpspan.instrument(mcp, capture_parameter_names=True)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
That records `{"destination": "string", "passengers": "number"}`, in JSON's
|
|
215
|
+
vocabulary, as the client sent them. Knowing `search_flights` is always
|
|
216
|
+
called with `destination` and never with `departure_date` tells you your
|
|
217
|
+
tool description is not landing. Knowing which destination tells you nothing
|
|
218
|
+
you needed, and puts your users' data somewhere it does not belong.
|
|
219
|
+
|
|
220
|
+
## Self-hosting
|
|
221
|
+
|
|
222
|
+
Point it at your own installation:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
mcpspan.instrument(mcp, endpoint="https://mcpspan.example.com")
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Or set `MCPSPAN_ENDPOINT`. There is no default: events go only where you point
|
|
229
|
+
them. With a key and no endpoint, nothing is collected, and the SDK says so
|
|
230
|
+
once on standard error.
|
|
231
|
+
|
|
232
|
+
When it starts with a key, the SDK sends one empty batch to say it is there.
|
|
233
|
+
That is how the dashboard's Status page tells a server nobody has used yet
|
|
234
|
+
from one pointed at the wrong address, and how a wrong key is reported when
|
|
235
|
+
your server starts rather than at its first tool call.
|
|
236
|
+
|
|
237
|
+
## It will not break your server
|
|
238
|
+
|
|
239
|
+
- Delivery happens on a background thread of its own, whether your server is
|
|
240
|
+
synchronous, on asyncio or on trio. A tool call returns without waiting on
|
|
241
|
+
the network, and the thread never keeps a process alive.
|
|
242
|
+
- A failure to send is never raised into your code. Retryable failures wait
|
|
243
|
+
and try again with a widening gap; a refused key switches collection off
|
|
244
|
+
and says so once on standard error.
|
|
245
|
+
- The queue is bounded. An unreachable endpoint cannot grow it until your
|
|
246
|
+
process runs out of memory.
|
|
247
|
+
- Nothing is ever written to standard output, which carries the MCP protocol
|
|
248
|
+
on a stdio server. Diagnostics go to standard error.
|
|
249
|
+
- `configure`, `instrument` and `shutdown` never raise. A mistyped option
|
|
250
|
+
falls back to its default rather than stopping your server from starting.
|
|
251
|
+
|
|
252
|
+
## Options
|
|
253
|
+
|
|
254
|
+
Given to `instrument` or `configure`.
|
|
255
|
+
|
|
256
|
+
| Option | Default | What it does |
|
|
257
|
+
|---|---|---|
|
|
258
|
+
| `api_key` | `MCPSPAN_API_KEY` | Identifies your server. Without it, nothing is collected. |
|
|
259
|
+
| `endpoint` | `MCPSPAN_ENDPOINT`; none | Your mcpspan installation. Nothing is collected without it. |
|
|
260
|
+
| `capture_parameter_names` | `False` | Records parameter names and types, never values. |
|
|
261
|
+
| `server_version` | `MCPSPAN_SERVER_VERSION`, then the server's own | The version to record calls under: a release, a tag, a commit. |
|
|
262
|
+
| `debug` | `False` | Writes delivery diagnostics to standard error. |
|
|
263
|
+
| `on_diagnostic` | - | Receives diagnostics instead. Implies `debug`. |
|
|
264
|
+
| `flush_on_exit` | `True` | Delivers what is queued as the interpreter exits. |
|
|
265
|
+
| `flush_interval` | `5.0` | Seconds a partly filled batch waits. |
|
|
266
|
+
| `max_batch_size` | `100` | Events per request. Reaching it sends early. |
|
|
267
|
+
| `max_queue_size` | `10000` | Events held while delivery is failing. |
|
|
268
|
+
|
|
269
|
+
Calling `configure` or `instrument` again with the same settings changes
|
|
270
|
+
nothing, so a server built per request can pass them every time.
|
|
271
|
+
|
|
272
|
+
## Developing
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
uv sync --group mcp2 # or mcp1, or fastmcp: the MCP SDK the tests run against
|
|
276
|
+
uv run --group mcp2 pytest
|
|
277
|
+
uv run --group mcp2 mypy
|
|
278
|
+
uv run ruff check && uv run ruff format --check
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The SDK follows [the contract every mcpspan SDK
|
|
282
|
+
follows](../../docs/sdk-contract.md), checked by the suite in
|
|
283
|
+
[`conformance/`](../../conformance/README.md).
|
|
284
|
+
|
|
285
|
+
## Licence
|
|
286
|
+
|
|
287
|
+
MIT.
|