mcp-telemetry 0.2.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.
- mcp_telemetry-0.2.0/LICENSE +21 -0
- mcp_telemetry-0.2.0/PKG-INFO +132 -0
- mcp_telemetry-0.2.0/README.md +104 -0
- mcp_telemetry-0.2.0/mcp_telemetry/__init__.py +41 -0
- mcp_telemetry-0.2.0/mcp_telemetry/api.py +98 -0
- mcp_telemetry-0.2.0/mcp_telemetry/cli.py +90 -0
- mcp_telemetry-0.2.0/mcp_telemetry/context.py +17 -0
- mcp_telemetry-0.2.0/mcp_telemetry/exporters.py +117 -0
- mcp_telemetry-0.2.0/mcp_telemetry/fastmcp.py +54 -0
- mcp_telemetry-0.2.0/mcp_telemetry/metrics.py +157 -0
- mcp_telemetry-0.2.0/mcp_telemetry/model.py +74 -0
- mcp_telemetry-0.2.0/mcp_telemetry/monkey.py +108 -0
- mcp_telemetry-0.2.0/mcp_telemetry/otel_provider.py +171 -0
- mcp_telemetry-0.2.0/mcp_telemetry/propagator.py +63 -0
- mcp_telemetry-0.2.0/mcp_telemetry/redact.py +54 -0
- mcp_telemetry-0.2.0/mcp_telemetry/replay.py +114 -0
- mcp_telemetry-0.2.0/mcp_telemetry/sampler.py +97 -0
- mcp_telemetry-0.2.0/mcp_telemetry/store.py +63 -0
- mcp_telemetry-0.2.0/mcp_telemetry.egg-info/PKG-INFO +132 -0
- mcp_telemetry-0.2.0/mcp_telemetry.egg-info/SOURCES.txt +26 -0
- mcp_telemetry-0.2.0/mcp_telemetry.egg-info/dependency_links.txt +1 -0
- mcp_telemetry-0.2.0/mcp_telemetry.egg-info/entry_points.txt +2 -0
- mcp_telemetry-0.2.0/mcp_telemetry.egg-info/requires.txt +15 -0
- mcp_telemetry-0.2.0/mcp_telemetry.egg-info/top_level.txt +1 -0
- mcp_telemetry-0.2.0/pyproject.toml +35 -0
- mcp_telemetry-0.2.0/setup.cfg +4 -0
- mcp_telemetry-0.2.0/tests/test_telemetry.py +105 -0
- mcp_telemetry-0.2.0/tests/test_tracing.py +200 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 threadwire
|
|
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.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-telemetry
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Zero-config observability for MCP servers. OTel GenAI conventions. Redaction on by default.
|
|
5
|
+
Author: threadwire
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/threadwire/mcp-telemetry
|
|
8
|
+
Project-URL: Repository, https://github.com/threadwire/mcp-telemetry
|
|
9
|
+
Keywords: mcp,model-context-protocol,observability,opentelemetry,ai,agents,tracing
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: Topic :: System :: Monitoring
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: otlp
|
|
17
|
+
Requires-Dist: httpx>=0.27; extra == "otlp"
|
|
18
|
+
Provides-Extra: mcp
|
|
19
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
20
|
+
Provides-Extra: admin
|
|
21
|
+
Requires-Dist: fastmcp>=2.0; extra == "admin"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
25
|
+
Requires-Dist: mcp>=1.0; extra == "dev"
|
|
26
|
+
Requires-Dist: fastmcp>=2.0; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# mcp-telemetry
|
|
30
|
+
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](#)
|
|
33
|
+
[](#)
|
|
34
|
+
|
|
35
|
+
Zero-config observability for MCP servers. OTel GenAI conventions. **Redaction on by default.**
|
|
36
|
+
|
|
37
|
+
MCP hit **97 million monthly SDK downloads** — and the production playbook is still being
|
|
38
|
+
written. OWASP's MCP Top 10 puts **"Lack of Audit and Telemetry"** at the top of the risk
|
|
39
|
+
list. This repo is that gap, filled in three lines.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install mcp-telemetry # core — zero dependencies
|
|
45
|
+
pip install 'mcp-telemetry[otlp]' # + OTLP/HTTP export (httpx)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Use — no changes to your server logic
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import mcp_telemetry as mt
|
|
52
|
+
|
|
53
|
+
mt.auto() # patches the official `mcp` SDK, writes JSONL
|
|
54
|
+
|
|
55
|
+
@mt.wrap_tool_call("issues.fetch", server="gh")
|
|
56
|
+
def fetch_issue(issue_id, token=""):
|
|
57
|
+
...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every call emits an OTel GenAI `gen_ai.client.tool_call` span with:
|
|
61
|
+
|
|
62
|
+
- **input fingerprint** — SHA-256 hash, never the raw payload
|
|
63
|
+
- **secret scrubbing** — `token`, `secret`, `api_key`-style keys → `[REDACTED]`
|
|
64
|
+
- latency, status, error type, server name
|
|
65
|
+
|
|
66
|
+
Manual spans and traces work too:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
with mt.session(): # one trace for the whole agent turn
|
|
70
|
+
with mt.span("chat.step"):
|
|
71
|
+
...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Distributed traces cross servers
|
|
75
|
+
|
|
76
|
+
Propagation is built in. A `traceparent` header on an inbound MCP request starts a
|
|
77
|
+
**continuation**, not a new trace — the span stamps `parent_span_id` and the trace id
|
|
78
|
+
carries through:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from mcp_telemetry.propagator import parse_traceparent
|
|
82
|
+
store.start(parse_traceparent(my_header).trace_id)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pair with [`mcp-hub`](https://github.com/threadwire/mcp-hub): the gateway relays W3C
|
|
86
|
+
`traceparent` verbatim to every upstream, and the responder below records the far side.
|
|
87
|
+
One trace, end to end. See [`examples/responder.py`](examples/responder.py) for a
|
|
88
|
+
stdlib-only server that does exactly this.
|
|
89
|
+
|
|
90
|
+
## Watch the firehose
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
mcp-trace # last 25 spans, ANSI table
|
|
94
|
+
mcp-trace --tail # follow the JSONL feed
|
|
95
|
+
mcp-trace --json | jq . # pipe raw records anywhere
|
|
96
|
+
mcp-trace --replay store.jsonl --console # offline replay → OTLP-shaped output
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Exporters
|
|
100
|
+
|
|
101
|
+
| Exporter | Where | Deps |
|
|
102
|
+
|-------------------|--------------------------------|---------|
|
|
103
|
+
| `JsonlExporter` | `mcp-telemetry.jsonl` | none |
|
|
104
|
+
| `TextExporter` | live stderr panel | none |
|
|
105
|
+
| `OtlpExporter` | Jaeger/Grafana/Datadog via OTLP/HTTP | `[otlp]` |
|
|
106
|
+
|
|
107
|
+
Spans follow OTel GenAI semantic conventions (`gen_ai.client.tool_call`,
|
|
108
|
+
`gen_ai.agent.invoke`) so traces land in your existing stack without a transform layer.
|
|
109
|
+
|
|
110
|
+
## Extended surface
|
|
111
|
+
|
|
112
|
+
- **Sampling** — `parent_based`, `ratio`, `rate_limited` (`mcp_telemetry.sampler`)
|
|
113
|
+
- **Metrics** — `Registry` + histogram buckets, `metrics_from_store` (`mcp_telemetry.metrics`)
|
|
114
|
+
- **OTel provider** — builds OTLP-shaped telemetry, `OtelProvider.export_built` (`mcp_telemetry.otel_provider`)
|
|
115
|
+
- **fastmcp** — opt-in shim: `mt.make_server()`, `patch_fastmcp` (`mcp_telemetry.fastmcp`)
|
|
116
|
+
- **Offline replay** — re-deliver any recorded JSONL through the exporter stack
|
|
117
|
+
|
|
118
|
+
## Overhead
|
|
119
|
+
|
|
120
|
+
`examples/bench.py`: **~78µs median, ~85µs p95 per instrumented call** (Python 3.14).
|
|
121
|
+
There's no free lunch, but at that cost you can trace every tool call in a hot agent loop.
|
|
122
|
+
|
|
123
|
+
## Design
|
|
124
|
+
|
|
125
|
+
- `monkey.py` — monkeypatches the official `mcp` SDK's `call_tool`; idempotent, no-ops cleanly when the SDK is absent
|
|
126
|
+
- `redact.py` — fingerprinting + secret scrubbing, deterministic hashes
|
|
127
|
+
- `store.py` / `api.py` — trace lifecycle + the three-line public surface
|
|
128
|
+
- `propagator.py` — W3C `traceparent`/`tracestate` continuation between services
|
|
129
|
+
- `replay.py` / `cli.py` — offline re-export + `mcp-trace` renderer/tail/replay
|
|
130
|
+
- Core import graph is **stdlib-only**; `httpx` lives behind `[otlp]`
|
|
131
|
+
|
|
132
|
+
MIT. Ship it.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# mcp-telemetry
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](#)
|
|
5
|
+
[](#)
|
|
6
|
+
|
|
7
|
+
Zero-config observability for MCP servers. OTel GenAI conventions. **Redaction on by default.**
|
|
8
|
+
|
|
9
|
+
MCP hit **97 million monthly SDK downloads** — and the production playbook is still being
|
|
10
|
+
written. OWASP's MCP Top 10 puts **"Lack of Audit and Telemetry"** at the top of the risk
|
|
11
|
+
list. This repo is that gap, filled in three lines.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install mcp-telemetry # core — zero dependencies
|
|
17
|
+
pip install 'mcp-telemetry[otlp]' # + OTLP/HTTP export (httpx)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use — no changes to your server logic
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import mcp_telemetry as mt
|
|
24
|
+
|
|
25
|
+
mt.auto() # patches the official `mcp` SDK, writes JSONL
|
|
26
|
+
|
|
27
|
+
@mt.wrap_tool_call("issues.fetch", server="gh")
|
|
28
|
+
def fetch_issue(issue_id, token=""):
|
|
29
|
+
...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Every call emits an OTel GenAI `gen_ai.client.tool_call` span with:
|
|
33
|
+
|
|
34
|
+
- **input fingerprint** — SHA-256 hash, never the raw payload
|
|
35
|
+
- **secret scrubbing** — `token`, `secret`, `api_key`-style keys → `[REDACTED]`
|
|
36
|
+
- latency, status, error type, server name
|
|
37
|
+
|
|
38
|
+
Manual spans and traces work too:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
with mt.session(): # one trace for the whole agent turn
|
|
42
|
+
with mt.span("chat.step"):
|
|
43
|
+
...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Distributed traces cross servers
|
|
47
|
+
|
|
48
|
+
Propagation is built in. A `traceparent` header on an inbound MCP request starts a
|
|
49
|
+
**continuation**, not a new trace — the span stamps `parent_span_id` and the trace id
|
|
50
|
+
carries through:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from mcp_telemetry.propagator import parse_traceparent
|
|
54
|
+
store.start(parse_traceparent(my_header).trace_id)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Pair with [`mcp-hub`](https://github.com/threadwire/mcp-hub): the gateway relays W3C
|
|
58
|
+
`traceparent` verbatim to every upstream, and the responder below records the far side.
|
|
59
|
+
One trace, end to end. See [`examples/responder.py`](examples/responder.py) for a
|
|
60
|
+
stdlib-only server that does exactly this.
|
|
61
|
+
|
|
62
|
+
## Watch the firehose
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
mcp-trace # last 25 spans, ANSI table
|
|
66
|
+
mcp-trace --tail # follow the JSONL feed
|
|
67
|
+
mcp-trace --json | jq . # pipe raw records anywhere
|
|
68
|
+
mcp-trace --replay store.jsonl --console # offline replay → OTLP-shaped output
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Exporters
|
|
72
|
+
|
|
73
|
+
| Exporter | Where | Deps |
|
|
74
|
+
|-------------------|--------------------------------|---------|
|
|
75
|
+
| `JsonlExporter` | `mcp-telemetry.jsonl` | none |
|
|
76
|
+
| `TextExporter` | live stderr panel | none |
|
|
77
|
+
| `OtlpExporter` | Jaeger/Grafana/Datadog via OTLP/HTTP | `[otlp]` |
|
|
78
|
+
|
|
79
|
+
Spans follow OTel GenAI semantic conventions (`gen_ai.client.tool_call`,
|
|
80
|
+
`gen_ai.agent.invoke`) so traces land in your existing stack without a transform layer.
|
|
81
|
+
|
|
82
|
+
## Extended surface
|
|
83
|
+
|
|
84
|
+
- **Sampling** — `parent_based`, `ratio`, `rate_limited` (`mcp_telemetry.sampler`)
|
|
85
|
+
- **Metrics** — `Registry` + histogram buckets, `metrics_from_store` (`mcp_telemetry.metrics`)
|
|
86
|
+
- **OTel provider** — builds OTLP-shaped telemetry, `OtelProvider.export_built` (`mcp_telemetry.otel_provider`)
|
|
87
|
+
- **fastmcp** — opt-in shim: `mt.make_server()`, `patch_fastmcp` (`mcp_telemetry.fastmcp`)
|
|
88
|
+
- **Offline replay** — re-deliver any recorded JSONL through the exporter stack
|
|
89
|
+
|
|
90
|
+
## Overhead
|
|
91
|
+
|
|
92
|
+
`examples/bench.py`: **~78µs median, ~85µs p95 per instrumented call** (Python 3.14).
|
|
93
|
+
There's no free lunch, but at that cost you can trace every tool call in a hot agent loop.
|
|
94
|
+
|
|
95
|
+
## Design
|
|
96
|
+
|
|
97
|
+
- `monkey.py` — monkeypatches the official `mcp` SDK's `call_tool`; idempotent, no-ops cleanly when the SDK is absent
|
|
98
|
+
- `redact.py` — fingerprinting + secret scrubbing, deterministic hashes
|
|
99
|
+
- `store.py` / `api.py` — trace lifecycle + the three-line public surface
|
|
100
|
+
- `propagator.py` — W3C `traceparent`/`tracestate` continuation between services
|
|
101
|
+
- `replay.py` / `cli.py` — offline re-export + `mcp-trace` renderer/tail/replay
|
|
102
|
+
- Core import graph is **stdlib-only**; `httpx` lives behind `[otlp]`
|
|
103
|
+
|
|
104
|
+
MIT. Ship it.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""mcp-telemetry — zero-config observability for MCP servers.
|
|
2
|
+
|
|
3
|
+
Core is stdlib-only. Optional exporters:
|
|
4
|
+
- OTLP/HTTP (needs httpx) -> pip install mcp-telemetry[otlp]
|
|
5
|
+
- built-in JSONL + live TUI terminal panel (no deps)
|
|
6
|
+
|
|
7
|
+
Extended surface — propagation, sampling, metrics, OTel provider, replay:
|
|
8
|
+
from mcp_telemetry.propagator import generate_traceparent, parse_traceparent
|
|
9
|
+
from mcp_telemetry.sampler import parent_based, ratio, rate_limited
|
|
10
|
+
from mcp_telemetry.metrics import Registry, metrics_from_store
|
|
11
|
+
from mcp_telemetry.otel_provider import OtelProvider
|
|
12
|
+
from mcp_telemetry.fastmcp import patch_fastmcp, make_server
|
|
13
|
+
mcp-trace --replay store.jsonl --console
|
|
14
|
+
"""
|
|
15
|
+
from mcp_telemetry.api import (
|
|
16
|
+
auto,
|
|
17
|
+
instrument,
|
|
18
|
+
session,
|
|
19
|
+
span,
|
|
20
|
+
trace_store,
|
|
21
|
+
)
|
|
22
|
+
from mcp_telemetry import monkey
|
|
23
|
+
from mcp_telemetry.monkey import wrap_tool_call, patch_mcp_sdk
|
|
24
|
+
from mcp_telemetry.store import TraceStore, default_store, set_default_store
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"auto",
|
|
28
|
+
"instrument",
|
|
29
|
+
"session",
|
|
30
|
+
"span",
|
|
31
|
+
"trace_store",
|
|
32
|
+
"monkey",
|
|
33
|
+
"wrap_tool_call",
|
|
34
|
+
"patch_mcp_sdk",
|
|
35
|
+
"TraceStore",
|
|
36
|
+
"default_store",
|
|
37
|
+
"set_default_store",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Public API for mcp-telemetry.
|
|
2
|
+
|
|
3
|
+
import mcp_telemetry as mt
|
|
4
|
+
mt.auto() # patch official mcp SDK + fanout to JSONL
|
|
5
|
+
mt.instrument(exporter=..., scrubbed=True)
|
|
6
|
+
|
|
7
|
+
with mt.span("chat"): # manual span around an LLM call
|
|
8
|
+
...
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import Iterator, Optional
|
|
16
|
+
|
|
17
|
+
from mcp_telemetry import context as _ctx
|
|
18
|
+
from mcp_telemetry import exporters
|
|
19
|
+
from mcp_telemetry import model
|
|
20
|
+
from mcp_telemetry import redact
|
|
21
|
+
from mcp_telemetry.monkey import is_patched, patch_mcp_sdk, wrap_tool_call
|
|
22
|
+
from mcp_telemetry.store import TraceStore, default_store, set_default_store
|
|
23
|
+
|
|
24
|
+
_ENABLED = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def instrument(
|
|
28
|
+
store: Optional[TraceStore] = None,
|
|
29
|
+
trace_exporter: Optional[exporters.Exporter] = None,
|
|
30
|
+
enabled: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Configure the active store + exporter fanout."""
|
|
33
|
+
global _ENABLED
|
|
34
|
+
eff = store or default_store()
|
|
35
|
+
if store is not None:
|
|
36
|
+
set_default_store(store)
|
|
37
|
+
_ENABLED = enabled
|
|
38
|
+
eff.on_record = exporters.DEFAULT.export if enabled else None
|
|
39
|
+
if trace_exporter is not None:
|
|
40
|
+
exporters.DEFAULT._targets = [trace_exporter]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def auto(store: Optional[TraceStore] = None, enabled: bool = True) -> bool:
|
|
44
|
+
"""All-in-one: enforce defaults + patch the official mcp SDK.
|
|
45
|
+
|
|
46
|
+
If the mcp SDK is missing, patch is a no-op and auto() still returns True
|
|
47
|
+
so callers can instrument() without knowing the runtime shape.
|
|
48
|
+
"""
|
|
49
|
+
instrument(store=store, enabled=enabled)
|
|
50
|
+
if enabled and "MCP_TELEMETRY_DISABLE" not in os.environ:
|
|
51
|
+
patch_mcp_sdk(store)
|
|
52
|
+
if enabled and sys.stderr.isatty():
|
|
53
|
+
exporters.DEFAULT.add(exporters.TextExporter())
|
|
54
|
+
return patch_mcp_sdk(store) or True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def session(trace_id: Optional[str] = None):
|
|
58
|
+
"""Context manager: start a trace, attach all nested spans to it."""
|
|
59
|
+
store = default_store()
|
|
60
|
+
trace = store.start(trace_id)
|
|
61
|
+
prev = _ctx.get_current()
|
|
62
|
+
_ctx.set_current(trace)
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def _scope() -> Iterator["TraceStore"]:
|
|
66
|
+
try:
|
|
67
|
+
yield store
|
|
68
|
+
finally:
|
|
69
|
+
store.record(trace)
|
|
70
|
+
_ctx.set_current(prev)
|
|
71
|
+
|
|
72
|
+
return _scope()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@contextmanager
|
|
76
|
+
def span(name: str, kind: str = model.SPAN_AGENT) -> Iterator[model.Span]:
|
|
77
|
+
"""Manual span. Attaches to the ambient session trace if one is open,
|
|
78
|
+
otherwise starts a standalone trace."""
|
|
79
|
+
store = default_store()
|
|
80
|
+
current = _ctx.get_current()
|
|
81
|
+
if current is not None:
|
|
82
|
+
trace = current
|
|
83
|
+
else:
|
|
84
|
+
trace = store.start()
|
|
85
|
+
sp = trace._make_span(name, None, kind=kind)
|
|
86
|
+
try:
|
|
87
|
+
yield sp
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
sp.finish("ERROR", f"{type(exc).__name__}: {exc}")
|
|
90
|
+
store.record(trace, finished=current is None)
|
|
91
|
+
raise
|
|
92
|
+
else:
|
|
93
|
+
sp.finish("OK")
|
|
94
|
+
store.record(trace, finished=current is None)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def trace_store() -> TraceStore:
|
|
98
|
+
return default_store()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""CLI: mcp-trace — render, tail, and clear the local telemetry feed.
|
|
2
|
+
|
|
3
|
+
mcp-trace # last 25 spans in ANSI table
|
|
4
|
+
mcp-trace --tail # follow the JSONL feed
|
|
5
|
+
mcp-trace --json # dump raw JSONL to stdout (pipe friendly)
|
|
6
|
+
mcp-trace --clear # truncate the local JSONL log
|
|
7
|
+
mcp-trace --replay <file> [--console] [--max N] # replay recorded traces through the exporter stack
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read(path: str) -> list[dict]:
|
|
17
|
+
rows: list[dict] = []
|
|
18
|
+
try:
|
|
19
|
+
with open(path, encoding="utf-8") as fh:
|
|
20
|
+
for line in fh:
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if line:
|
|
23
|
+
rows.append(json.loads(line))
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
pass
|
|
26
|
+
return rows
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _render(rows: list[dict]) -> None:
|
|
30
|
+
for tr in rows[-25:]:
|
|
31
|
+
for s in tr["spans"]:
|
|
32
|
+
hit = "OK" if s["status"] == "OK" else "ERR"
|
|
33
|
+
color = 32 if hit == "OK" else 31
|
|
34
|
+
print(
|
|
35
|
+
f"\x1b[36m{tr['id']}\x1b[0m "
|
|
36
|
+
f"\x1b[1m{s['tool']}\x1b[0m "
|
|
37
|
+
f"\x1b[{color}m{hit}\x1b[0m "
|
|
38
|
+
f"\x1b[33m{s.get('latency_ms') or 0:.1f}ms\x1b[0m "
|
|
39
|
+
f"in#{s.get('input_hash') or '-'} "
|
|
40
|
+
+ (f"\x1b[31m{s['error']}\x1b[0m" if s.get("error") else "")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> int:
|
|
45
|
+
parser = argparse.ArgumentParser(prog="mcp-trace", description="Render the mcp-telemetry local feed")
|
|
46
|
+
parser.add_argument("--tail", action="store_true", help="follow the JSONL log")
|
|
47
|
+
parser.add_argument("--json", action="store_true", help="dump raw records as NDJSON")
|
|
48
|
+
parser.add_argument("--clear", action="store_true", help="clear the local log")
|
|
49
|
+
parser.add_argument("--replay", metavar="FILE", help="replay a recorded JSONL store through the exporter stack")
|
|
50
|
+
parser.add_argument("--console", action="store_true", help="with --replay, print OTLP-shaped traces")
|
|
51
|
+
parser.add_argument("--max", type=int, metavar="N", help="with --replay, cap the number of traces")
|
|
52
|
+
parser.add_argument("--path", default="mcp-telemetry.jsonl", help="log file path")
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
if args.replay is not None:
|
|
56
|
+
from mcp_telemetry.replay import main as replay_main
|
|
57
|
+
|
|
58
|
+
sub = [args.replay]
|
|
59
|
+
if args.console:
|
|
60
|
+
sub.append("--console")
|
|
61
|
+
if args.max:
|
|
62
|
+
sub += ["--max", str(args.max)]
|
|
63
|
+
return replay_main(sub)
|
|
64
|
+
|
|
65
|
+
if args.clear:
|
|
66
|
+
open(args.path, "w", encoding="utf-8").close() # noqa: SIM115
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
if args.tail:
|
|
70
|
+
seen = 0
|
|
71
|
+
while True:
|
|
72
|
+
rows = _read(args.path)
|
|
73
|
+
for tr in rows[seen:]:
|
|
74
|
+
for s in tr["spans"]:
|
|
75
|
+
print(json.dumps({"trace": tr["id"], **s}))
|
|
76
|
+
seen += 1
|
|
77
|
+
time.sleep(0.5)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
rows = _read(args.path)
|
|
81
|
+
if args.json:
|
|
82
|
+
for tr in rows:
|
|
83
|
+
print(json.dumps(tr))
|
|
84
|
+
return 0
|
|
85
|
+
_render(rows)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Ambient trace cursor shared across api.py and monkey.py without cycles."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from mcp_telemetry.model import Trace
|
|
7
|
+
|
|
8
|
+
_current: Optional[Trace] = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_current(trace: Optional[Trace]) -> None:
|
|
12
|
+
global _current
|
|
13
|
+
_current = trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_current() -> Optional[Trace]:
|
|
17
|
+
return _current
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Span payload builders + exporters.
|
|
2
|
+
|
|
3
|
+
Exporters implement `dump(trace) -> str` so they compose cleanly:
|
|
4
|
+
- JsonlExporter -> ndjson for pipes / local debug
|
|
5
|
+
- OtlpExporter -> OTLP/HTTP JSON payload (httpx optional extra)
|
|
6
|
+
- TextExporter -> ANSI table for the live TUI panel (stdlib only)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
from mcp_telemetry.model import Trace
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Exporter(Protocol):
|
|
21
|
+
def export(self, trace: Trace) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class JsonlExporter:
|
|
26
|
+
path: str = "mcp-telemetry.jsonl"
|
|
27
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
28
|
+
|
|
29
|
+
def export(self, trace: Trace) -> None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
with open(self.path, "a", encoding="utf-8") as fh:
|
|
32
|
+
fh.write(json.dumps(trace.to_dict()) + "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TextExporter:
|
|
37
|
+
"""Minimal ANSI 256-color renderer. No external deps."""
|
|
38
|
+
|
|
39
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
40
|
+
|
|
41
|
+
def export(self, trace: Trace) -> None:
|
|
42
|
+
rows = []
|
|
43
|
+
for s in trace.spans:
|
|
44
|
+
status = "\x1b[32mOK\x1b[0m" if s.status == "OK" else f"\x1b[31m{s.status}\x1b[0m"
|
|
45
|
+
lines = [
|
|
46
|
+
f" \x1b[36m{trace.id}\x1b[0m {s.tool or s.name} {status} "
|
|
47
|
+
f"\x1b[33m{s.latency_ms or 0:.1f}ms\x1b[0m"
|
|
48
|
+
]
|
|
49
|
+
if s.input_hash:
|
|
50
|
+
lines.append(f" input#{s.input_hash} {s.server or ''}")
|
|
51
|
+
if s.error:
|
|
52
|
+
lines.append(f" \x1b[31m{s.error}\x1b[0m")
|
|
53
|
+
rows.extend(lines)
|
|
54
|
+
payload = "\n".join(rows)
|
|
55
|
+
with self._lock:
|
|
56
|
+
print(payload, file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class OtlpExporter:
|
|
61
|
+
"""OTLP/HTTP JSON (v1 logs/bridge). Requires httpx (extra: [otlp]).
|
|
62
|
+
|
|
63
|
+
Sends one span record per tool call with gen_ai keys in attributes.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
endpoint: str = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/logs")
|
|
67
|
+
service_name: str = os.environ.get("OTEL_SERVICE_NAME", "mcp-server")
|
|
68
|
+
headers: dict[str, str] = field(default_factory=lambda: {"Content-Type": "application/json"})
|
|
69
|
+
|
|
70
|
+
def export(self, trace: Trace) -> None:
|
|
71
|
+
try:
|
|
72
|
+
import httpx # type: ignore
|
|
73
|
+
except ImportError as exc: # pragma: no cover
|
|
74
|
+
raise RuntimeError("OTLP export requires httpx: pip install 'mcp-telemetry[otlp]'") from exc
|
|
75
|
+
|
|
76
|
+
records = []
|
|
77
|
+
for s in trace.spans:
|
|
78
|
+
attrs = {
|
|
79
|
+
"gen_ai.tool.name": s.tool or s.name,
|
|
80
|
+
"gen_ai.server.name": s.server or "",
|
|
81
|
+
"gen_ai.input.bytes": s.input_hash or "",
|
|
82
|
+
"gen_ai.usage.input_tokens": s.tokens_in or 0,
|
|
83
|
+
"gen_ai.usage.output_tokens": s.tokens_out or 0,
|
|
84
|
+
"gen_ai.operation.span.id": trace.id,
|
|
85
|
+
"gen_ai.operation.name": s.name,
|
|
86
|
+
}
|
|
87
|
+
if s.error:
|
|
88
|
+
attrs["gen_ai.error.type"] = s.error
|
|
89
|
+
records.append(
|
|
90
|
+
{
|
|
91
|
+
"scope": {"name": "mcp-telemetry", "version": "0.1.0"},
|
|
92
|
+
"resource": {"service.name": self.service_name},
|
|
93
|
+
"severity_text": "ERROR" if s.error else "INFO",
|
|
94
|
+
"body": {"string_value": f"{s.tool} {s.status}"},
|
|
95
|
+
"attributes": attrs,
|
|
96
|
+
"timeUnixNano": str(int((s.end or s.start) * 1_000_000_000)),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
httpx.post(self.endpoint, json={"resourceLogs": [{"scopeLogs": [{"logRecords": records}]}]}, headers=self.headers, timeout=5.0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Fanout:
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._targets: list[Exporter] = [JsonlExporter()]
|
|
105
|
+
|
|
106
|
+
def add(self, exporter: Exporter) -> None:
|
|
107
|
+
self._targets.append(exporter)
|
|
108
|
+
|
|
109
|
+
def export(self, trace: Trace) -> None:
|
|
110
|
+
for t in self._targets:
|
|
111
|
+
try:
|
|
112
|
+
t.export(trace)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
DEFAULT = Fanout()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""FastMCP integration — one-call instrumentation.
|
|
2
|
+
|
|
3
|
+
`patch_fastmcp(server)` wraps every tool handler the FastMCP server registers
|
|
4
|
+
so each invocation emits a GenAI-style trace span, deduped per tool, feeding
|
|
5
|
+
the active TraceStore/provider. No SDK import required to USE this module; the
|
|
6
|
+
SDK is fetched lazily so `pip install mcp-telemetry` alone is harmless.
|
|
7
|
+
|
|
8
|
+
If you do not run FastMCP, call `patch_mcp_sdk()` from the core to auto-wrap
|
|
9
|
+
the stdlib/sdk tool handlers instead — the same spans drop out underneath.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
from mcp_telemetry.api import span
|
|
16
|
+
from mcp_telemetry.monkey import wrap_tool_call
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def patch_fastmcp(server: Any, *, store: Any = None) -> int:
|
|
20
|
+
"""Wrap all handlers currently registered on a FastMCP server object. The
|
|
21
|
+
instance may be post-`@server.tool(...)` decorated — we walk `server._tool_manager`."""
|
|
22
|
+
try:
|
|
23
|
+
import fastmcp # noqa: F401
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise RuntimeError("fastmcp not installed — import it or pip install fastmcp")
|
|
26
|
+
|
|
27
|
+
tool_manager = getattr(server, "_tool_manager", None)
|
|
28
|
+
if tool_manager is None:
|
|
29
|
+
return 0
|
|
30
|
+
tools = getattr(tool_manager, "_tools", None) or getattr(tool_manager, "tools", {})
|
|
31
|
+
wrapped = 0
|
|
32
|
+
for tool in tools.values() if isinstance(tools, dict) else tools:
|
|
33
|
+
fn = getattr(tool, "fn", None) or getattr(tool, "func", None)
|
|
34
|
+
if fn is None:
|
|
35
|
+
continue
|
|
36
|
+
name = getattr(tool, "name", None) or getattr(fn, "__name__", "tool")
|
|
37
|
+
tool.fn = wrap_tool_call(fn, store=store, tool_name=name) # type: ignore[attr-defined]
|
|
38
|
+
wrapped += 1
|
|
39
|
+
return wrapped
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_server(name: str, store: Any) -> Any:
|
|
43
|
+
"""Slim FastMCP-compatible shim for a server name: emits a trace span per
|
|
44
|
+
tool invocation and records it. Use when you want instrumentation without
|
|
45
|
+
the SDK present — identical span shape, so replays report the same way."""
|
|
46
|
+
# class bodies do not close over enclosing function locals — build via type()
|
|
47
|
+
def tool(fn: Callable[..., Any], *, tool_name: Optional[str] = None) -> Callable[..., Any]:
|
|
48
|
+
return wrap_tool_call(fn, name=tool_name or getattr(fn, "__name__", "tool"), store=store)
|
|
49
|
+
|
|
50
|
+
shim = type("FastMcpShim", (), {"name": name, "tool": staticmethod(tool)})()
|
|
51
|
+
return shim
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["patch_fastmcp", "make_server"]
|