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.
- trackmcp-0.1.0/PKG-INFO +23 -0
- trackmcp-0.1.0/README.md +12 -0
- trackmcp-0.1.0/pyproject.toml +16 -0
- trackmcp-0.1.0/setup.cfg +4 -0
- trackmcp-0.1.0/tests/test_client.py +46 -0
- trackmcp-0.1.0/trackmcp/__init__.py +3 -0
- trackmcp-0.1.0/trackmcp/client.py +242 -0
- trackmcp-0.1.0/trackmcp.egg-info/PKG-INFO +23 -0
- trackmcp-0.1.0/trackmcp.egg-info/SOURCES.txt +9 -0
- trackmcp-0.1.0/trackmcp.egg-info/dependency_links.txt +1 -0
- trackmcp-0.1.0/trackmcp.egg-info/top_level.txt +1 -0
trackmcp-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
trackmcp-0.1.0/README.md
ADDED
|
@@ -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*"]
|
trackmcp-0.1.0/setup.cfg
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trackmcp
|