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.
@@ -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: MIT](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
32
+ [![python](https://img.shields.io/badge/python-%3E%3D3.10-3670A0)](#)
33
+ [![runtime deps](https://img.shields.io/badge/core_deps-0-brightgreen)](#)
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: MIT](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
4
+ [![python](https://img.shields.io/badge/python-%3E%3D3.10-3670A0)](#)
5
+ [![runtime deps](https://img.shields.io/badge/core_deps-0-brightgreen)](#)
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"]