trackmcp 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.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: trackmcp
3
+ Version: 0.1.0
4
+ Summary: Observability SDK for MCP servers
5
+ License: MIT
6
+ Keywords: mcp,model-context-protocol,observability,analytics
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # trackmcp
13
+
14
+ ```python
15
+ import os
16
+ from trackmcp import with_trackmcp
17
+
18
+ app = with_trackmcp(server, api_key=os.environ["TRACKMCP_KEY"], service="my-mcp-server")
19
+ ```
20
+
21
+ Telemetry is batched, redacted locally, and fail-open.
22
+
23
+ For an explicit business outcome, optionally call `tracked_server.trackmcp.workflow("issue_resolution", "completed")` from your application code.
@@ -0,0 +1,12 @@
1
+ # trackmcp
2
+
3
+ ```python
4
+ import os
5
+ from trackmcp import with_trackmcp
6
+
7
+ app = with_trackmcp(server, api_key=os.environ["TRACKMCP_KEY"], service="my-mcp-server")
8
+ ```
9
+
10
+ Telemetry is batched, redacted locally, and fail-open.
11
+
12
+ For an explicit business outcome, optionally call `tracked_server.trackmcp.workflow("issue_resolution", "completed")` from your application code.
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trackmcp"
7
+ version = "0.1.0"
8
+ description = "Observability SDK for MCP servers"
9
+ requires-python = ">=3.9"
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+ keywords = ["mcp", "model-context-protocol", "observability", "analytics"]
13
+ classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License"]
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["trackmcp*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,46 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ from unittest import TestCase
5
+
6
+ from trackmcp import TrackMCP, TrackMCPOptions
7
+
8
+
9
+ class Handler(BaseHTTPRequestHandler):
10
+ payload = None
11
+
12
+ def do_POST(self):
13
+ length = int(self.headers["content-length"])
14
+ Handler.payload = json.loads(self.rfile.read(length))
15
+ self.send_response(200)
16
+ self.end_headers()
17
+
18
+ def log_message(self, *_args):
19
+ pass
20
+
21
+
22
+ class TrackMCPClientTest(TestCase):
23
+ def test_flushes_redacted_event(self):
24
+ server = HTTPServer(("127.0.0.1", 0), Handler)
25
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
26
+ thread.start()
27
+ client = TrackMCP(TrackMCPOptions(
28
+ api_key="tmcp_test",
29
+ endpoint=f"http://127.0.0.1:{server.server_port}",
30
+ redact=["args.password"],
31
+ ))
32
+ client.capture({
33
+ "event_type": "tool_call",
34
+ "tool_name": "lookup",
35
+ "started_at": "2026-01-01T00:00:00Z",
36
+ "payload": {"args": {"password": "secret"}},
37
+ })
38
+ client.flush()
39
+ server.shutdown()
40
+ server.server_close()
41
+ self.assertEqual(Handler.payload["events"][0]["payload"]["args"]["password"], "[redacted]")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ import unittest
46
+ unittest.main()
@@ -0,0 +1,3 @@
1
+ from .client import TrackMCP, TrackMCPOptions, with_trackmcp, track
2
+
3
+ __all__ = ["TrackMCP", "TrackMCPOptions", "with_trackmcp", "track"]
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import inspect
5
+ import json
6
+ import os
7
+ import random
8
+ import threading
9
+ import time
10
+ import urllib.request
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, Iterable, List, Optional
14
+
15
+
16
+ _active_client: Optional["TrackMCP"] = None
17
+
18
+
19
+ @dataclass
20
+ class TrackMCPOptions:
21
+ api_key: str
22
+ service: str = "mcp-server"
23
+ environment: str = field(default_factory=lambda: os.getenv("NODE_ENV", "production"))
24
+ endpoint: str = "https://trackmcp.com/api/v1/ingest"
25
+ sample_rate: float = 1.0
26
+ redact: List[str] = field(default_factory=list)
27
+ disabled: bool = False
28
+ server_version: Optional[str] = None
29
+ sdk_version: Optional[str] = None
30
+ deployment_id: Optional[str] = None
31
+ flush_interval_ms: int = 5000
32
+ max_batch_size: int = 50
33
+
34
+
35
+ def _redact(value: Any, paths: Iterable[str]) -> Any:
36
+ result = copy.deepcopy(value)
37
+ if not isinstance(result, dict):
38
+ return result
39
+ for path in paths:
40
+ cursor: Any = result
41
+ parts = path.split(".")
42
+ for part in parts[:-1]:
43
+ if not isinstance(cursor, dict) or part not in cursor:
44
+ cursor = None
45
+ break
46
+ cursor = cursor[part]
47
+ if isinstance(cursor, dict) and parts[-1] in cursor:
48
+ cursor[parts[-1]] = "[redacted]"
49
+ return result
50
+
51
+
52
+ class TrackMCP:
53
+ def __init__(self, options: TrackMCPOptions):
54
+ if not options.api_key:
55
+ raise ValueError("TrackMCP api_key is required")
56
+ self.options = options
57
+ self._events: List[Dict[str, Any]] = []
58
+ self._lock = threading.Lock()
59
+ self._timer = None
60
+ if not options.disabled:
61
+ self._timer = threading.Timer(options.flush_interval_ms / 1000, self._scheduled_flush)
62
+ self._timer.daemon = True
63
+ self._timer.start()
64
+
65
+ def capture(self, event: Dict[str, Any]) -> None:
66
+ if self.options.disabled or random.random() > max(0, min(1, self.options.sample_rate)):
67
+ return
68
+ event = dict(event)
69
+ event.update({
70
+ "event_id": str(uuid.uuid4()),
71
+ "service": self.options.service,
72
+ "environment": self.options.environment,
73
+ "server_version": self.options.server_version,
74
+ "sdk_version": self.options.sdk_version,
75
+ "deployment_id": self.options.deployment_id,
76
+ })
77
+ if "payload" in event:
78
+ event["payload"] = _redact(event["payload"], self.options.redact)
79
+ with self._lock:
80
+ self._events.append(event)
81
+ should_flush = len(self._events) >= self.options.max_batch_size
82
+ if should_flush:
83
+ self.flush()
84
+
85
+ def track(self, name: str, payload: Optional[Dict[str, Any]] = None) -> None:
86
+ self.capture({"event_type": "custom", "started_at": _iso_now(), "payload": {"name": name, **(payload or {})}})
87
+
88
+ def workflow(self, name: str, status: str, payload: Optional[Dict[str, Any]] = None) -> None:
89
+ if status not in ("started", "completed", "failed"):
90
+ raise ValueError("workflow status must be started, completed, or failed")
91
+ self.track("workflow", {"workflow_name": name, "status": status, **(payload or {})})
92
+
93
+ def flush(self) -> None:
94
+ with self._lock:
95
+ events = self._events[: self.options.max_batch_size]
96
+ del self._events[: len(events)]
97
+ if not events or self.options.disabled:
98
+ return
99
+ try:
100
+ body = json.dumps({"events": events}).encode("utf-8")
101
+ request = urllib.request.Request(
102
+ self.options.endpoint,
103
+ data=body,
104
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.options.api_key}"},
105
+ method="POST",
106
+ )
107
+ with urllib.request.urlopen(request, timeout=3) as response:
108
+ if response.status >= 300:
109
+ raise RuntimeError(f"TrackMCP ingest returned {response.status}")
110
+ except Exception:
111
+ with self._lock:
112
+ self._events[0:0] = events
113
+
114
+ def _scheduled_flush(self) -> None:
115
+ self.flush()
116
+ if not self.options.disabled:
117
+ self._timer = threading.Timer(self.options.flush_interval_ms / 1000, self._scheduled_flush)
118
+ self._timer.daemon = True
119
+ self._timer.start()
120
+
121
+
122
+ class _TrackMCPMiddleware:
123
+ """Official MCP Python SDK middleware seam (mcp 2.x)."""
124
+
125
+ def __init__(self, client: TrackMCP):
126
+ self.client = client
127
+ self.client_name: Optional[str] = None
128
+
129
+ async def __call__(self, ctx: Any, call_next: Any) -> Any:
130
+ method = getattr(ctx, "method", "")
131
+ params = getattr(ctx, "params", None) or {}
132
+ if method == "initialize" and isinstance(params, dict):
133
+ client_info = params.get("clientInfo")
134
+ if isinstance(client_info, dict):
135
+ self.client_name = client_info.get("name")
136
+ session_id = getattr(ctx, "session_id", None)
137
+ request_id = getattr(ctx, "request_id", None)
138
+ tool_name = params.get("name") if isinstance(params, dict) else None
139
+ arguments = params.get("arguments", {}) if isinstance(params, dict) else {}
140
+ started = time.time()
141
+ try:
142
+ result = await call_next(ctx)
143
+ result_data = result.model_dump(mode="json") if hasattr(result, "model_dump") else result
144
+ is_error = bool(isinstance(result_data, dict) and result_data.get("isError"))
145
+ if method == "tools/call":
146
+ self.client.capture(_event(tool_name, {"args": arguments}, started, result_data, None, self.client_name, session_id, request_id))
147
+ elif method in ("tools/list", "resources/list", "resources/templates/list", "prompts/list"):
148
+ catalog_type = method.replace("/list", "")
149
+ self.client.capture({"event_type": "catalog", "mcp_method": method, "request_id": request_id, "session_id": session_id, "client_name": self.client_name, "started_at": _iso_from_epoch(started), "duration_ms": round((time.time() - started) * 1000), "success": not is_error, "is_error": is_error, "payload": {"name": f"{catalog_type}_discovered", "result": result_data}})
150
+ else:
151
+ self.client.capture({"event_type": "protocol", "mcp_method": method, "request_id": request_id, "session_id": session_id, "client_name": self.client_name, "started_at": _iso_from_epoch(started), "duration_ms": round((time.time() - started) * 1000), "success": not is_error, "is_error": is_error, "payload": {"params": params, "result": result_data}})
152
+ return result
153
+ except Exception as error:
154
+ if method == "tools/call":
155
+ self.client.capture(_event(tool_name, {"args": arguments}, started, None, error, self.client_name, session_id, request_id))
156
+ else:
157
+ self.client.capture({"event_type": "protocol", "mcp_method": method, "request_id": request_id, "session_id": session_id, "client_name": self.client_name, "started_at": _iso_from_epoch(started), "duration_ms": round((time.time() - started) * 1000), "success": False, "is_error": True, "error_class": "protocol_error", "payload": {"params": params, "error": str(error)}})
158
+ raise
159
+
160
+ def _iso_now() -> str:
161
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
162
+
163
+
164
+ def _iso_from_epoch(value: float) -> str:
165
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(value))
166
+
167
+
168
+ def _details(args: tuple[Any, ...]) -> tuple[Optional[str], Dict[str, Any]]:
169
+ first = args[0] if args else {}
170
+ if not isinstance(first, dict):
171
+ return None, {"args": list(args)}
172
+ params = first.get("params", first)
173
+ if not isinstance(params, dict):
174
+ params = first
175
+ return params.get("name"), {"args": params.get("arguments", params.get("args", {}))}
176
+
177
+
178
+ class _Wrapped:
179
+ def __init__(self, server: Any, client: TrackMCP):
180
+ self._server = server
181
+ self.trackmcp = client
182
+
183
+ def __getattr__(self, name: str) -> Any:
184
+ original = getattr(self._server, name)
185
+ if name not in ("request", "call_tool", "callTool") or not callable(original):
186
+ return original
187
+
188
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
189
+ tool_name, payload = _details(args)
190
+ started = time.time()
191
+ try:
192
+ result = original(*args, **kwargs)
193
+ if inspect.isawaitable(result):
194
+ async def awaited() -> Any:
195
+ try:
196
+ value = await result
197
+ client.capture(_event(tool_name, payload, started, value, None))
198
+ return value
199
+ except Exception as error:
200
+ client.capture(_event(tool_name, payload, started, None, error))
201
+ raise
202
+ return awaited()
203
+ client.capture(_event(tool_name, payload, started, result, None))
204
+ return result
205
+ except Exception as error:
206
+ client.capture(_event(tool_name, payload, started, None, error))
207
+ raise
208
+ return wrapped
209
+
210
+
211
+ def _event(tool_name: Optional[str], payload: Dict[str, Any], started: float, result: Any, error: Optional[Exception], client_name: Optional[str] = None, session_id: Optional[str] = None, request_id: Optional[str] = None) -> Dict[str, Any]:
212
+ is_error = error is not None or bool(isinstance(result, dict) and result.get("isError"))
213
+ return {
214
+ "event_type": "tool_call",
215
+ "tool_name": tool_name,
216
+ "mcp_method": "tools/call",
217
+ "request_id": request_id,
218
+ "client_name": client_name,
219
+ "session_id": session_id,
220
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(started)),
221
+ "duration_ms": round((time.time() - started) * 1000),
222
+ "success": not is_error,
223
+ "is_error": is_error,
224
+ "payload": {**payload, "result": result, "error": str(error) if error else None},
225
+ }
226
+
227
+
228
+ def with_trackmcp(server: Any, api_key: Optional[str] = None, **kwargs: Any) -> Any:
229
+ global _active_client
230
+ options = TrackMCPOptions(api_key=api_key or os.environ["TRACKMCP_KEY"], **kwargs)
231
+ _active_client = TrackMCP(options)
232
+ official_server = getattr(server, "_lowlevel_server", None)
233
+ if official_server is not None and hasattr(official_server, "middleware"):
234
+ official_server.middleware.append(_TrackMCPMiddleware(_active_client))
235
+ setattr(server, "trackmcp", _active_client)
236
+ return server
237
+ return _Wrapped(server, _active_client)
238
+
239
+
240
+ def track(name: str, payload: Optional[Dict[str, Any]] = None) -> None:
241
+ if _active_client:
242
+ _active_client.track(name, payload)
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: trackmcp
3
+ Version: 0.1.0
4
+ Summary: Observability SDK for MCP servers
5
+ License: MIT
6
+ Keywords: mcp,model-context-protocol,observability,analytics
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # trackmcp
13
+
14
+ ```python
15
+ import os
16
+ from trackmcp import with_trackmcp
17
+
18
+ app = with_trackmcp(server, api_key=os.environ["TRACKMCP_KEY"], service="my-mcp-server")
19
+ ```
20
+
21
+ Telemetry is batched, redacted locally, and fail-open.
22
+
23
+ For an explicit business outcome, optionally call `tracked_server.trackmcp.workflow("issue_resolution", "completed")` from your application code.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_client.py
4
+ trackmcp/__init__.py
5
+ trackmcp/client.py
6
+ trackmcp.egg-info/PKG-INFO
7
+ trackmcp.egg-info/SOURCES.txt
8
+ trackmcp.egg-info/dependency_links.txt
9
+ trackmcp.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ trackmcp