scope-sdk 0.1.0__py3-none-any.whl
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.
- scope_sdk/__init__.py +3 -0
- scope_sdk/client.py +550 -0
- scope_sdk/transport.py +132 -0
- scope_sdk-0.1.0.dist-info/METADATA +10 -0
- scope_sdk-0.1.0.dist-info/RECORD +7 -0
- scope_sdk-0.1.0.dist-info/WHEEL +5 -0
- scope_sdk-0.1.0.dist-info/top_level.txt +1 -0
scope_sdk/__init__.py
ADDED
scope_sdk/client.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import datetime
|
|
3
|
+
import uuid
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
import contextvars
|
|
8
|
+
import logging
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import getpass
|
|
12
|
+
from .transport import ScopeTransport
|
|
13
|
+
|
|
14
|
+
SDK_VERSION = "0.1.0"
|
|
15
|
+
SDK_LANGUAGE = "python"
|
|
16
|
+
|
|
17
|
+
# Thread-safe context for logging and network interception
|
|
18
|
+
log_context = contextvars.ContextVar("scope_log_context", default=None)
|
|
19
|
+
network_context = contextvars.ContextVar("scope_network_context", default=None)
|
|
20
|
+
active_request = contextvars.ContextVar("scope_active_request", default=None)
|
|
21
|
+
|
|
22
|
+
# ── Logging interceptor ──
|
|
23
|
+
class ScopeLoggingHandler(logging.Handler):
|
|
24
|
+
def emit(self, record):
|
|
25
|
+
store = log_context.get()
|
|
26
|
+
if store is not None:
|
|
27
|
+
try:
|
|
28
|
+
store.append({
|
|
29
|
+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
|
30
|
+
"level": record.levelname.lower(),
|
|
31
|
+
"message": self.format(record)
|
|
32
|
+
})
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
# Add logging handler to root logger
|
|
37
|
+
try:
|
|
38
|
+
logging.getLogger().addHandler(ScopeLoggingHandler())
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Network call interceptor ──
|
|
44
|
+
_httpx_hooked = False
|
|
45
|
+
def setup_network_hooks():
|
|
46
|
+
global _httpx_hooked
|
|
47
|
+
if _httpx_hooked:
|
|
48
|
+
return
|
|
49
|
+
_httpx_hooked = True
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import httpx
|
|
53
|
+
|
|
54
|
+
# 1. Sync httpx interception
|
|
55
|
+
orig_send = httpx.Client.send
|
|
56
|
+
def new_send(self, request, *args, **kwargs):
|
|
57
|
+
store = network_context.get()
|
|
58
|
+
if store is None or request.url.path.endswith("/ingest"):
|
|
59
|
+
return orig_send(self, request, *args, **kwargs)
|
|
60
|
+
|
|
61
|
+
network_call = {
|
|
62
|
+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
|
63
|
+
"url": str(request.url),
|
|
64
|
+
"method": request.method,
|
|
65
|
+
"request_headers": dict(request.headers),
|
|
66
|
+
"success": False
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
start = time.perf_counter()
|
|
70
|
+
try:
|
|
71
|
+
res = orig_send(self, request, *args, **kwargs)
|
|
72
|
+
dur = int((time.perf_counter() - start) * 1000)
|
|
73
|
+
|
|
74
|
+
network_call["status"] = res.status_code
|
|
75
|
+
network_call["duration_ms"] = dur
|
|
76
|
+
network_call["response_headers"] = dict(res.headers)
|
|
77
|
+
try:
|
|
78
|
+
network_call["response_body"] = res.json()
|
|
79
|
+
except Exception:
|
|
80
|
+
network_call["response_body"] = res.text
|
|
81
|
+
network_call["success"] = res.status_code < 400
|
|
82
|
+
|
|
83
|
+
store.append(network_call)
|
|
84
|
+
return res
|
|
85
|
+
except Exception as e:
|
|
86
|
+
dur = int((time.perf_counter() - start) * 1000)
|
|
87
|
+
network_call["duration_ms"] = dur
|
|
88
|
+
network_call["error"] = str(e)
|
|
89
|
+
store.append(network_call)
|
|
90
|
+
raise e
|
|
91
|
+
|
|
92
|
+
httpx.Client.send = new_send
|
|
93
|
+
|
|
94
|
+
# 2. Async httpx interception
|
|
95
|
+
orig_async_send = httpx.AsyncClient.send
|
|
96
|
+
async def new_async_send(self, request, *args, **kwargs):
|
|
97
|
+
store = network_context.get()
|
|
98
|
+
if store is None or request.url.path.endswith("/ingest"):
|
|
99
|
+
return await orig_async_send(self, request, *args, **kwargs)
|
|
100
|
+
|
|
101
|
+
network_call = {
|
|
102
|
+
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
|
103
|
+
"url": str(request.url),
|
|
104
|
+
"method": request.method,
|
|
105
|
+
"request_headers": dict(request.headers),
|
|
106
|
+
"success": False
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
start = time.perf_counter()
|
|
110
|
+
try:
|
|
111
|
+
res = await orig_async_send(self, request, *args, **kwargs)
|
|
112
|
+
dur = int((time.perf_counter() - start) * 1000)
|
|
113
|
+
|
|
114
|
+
network_call["status"] = res.status_code
|
|
115
|
+
network_call["duration_ms"] = dur
|
|
116
|
+
network_call["response_headers"] = dict(res.headers)
|
|
117
|
+
try:
|
|
118
|
+
network_call["response_body"] = res.json()
|
|
119
|
+
except Exception:
|
|
120
|
+
network_call["response_body"] = res.text
|
|
121
|
+
network_call["success"] = res.status_code < 400
|
|
122
|
+
|
|
123
|
+
store.append(network_call)
|
|
124
|
+
return res
|
|
125
|
+
except Exception as e:
|
|
126
|
+
dur = int((time.perf_counter() - start) * 1000)
|
|
127
|
+
network_call["duration_ms"] = dur
|
|
128
|
+
network_call["error"] = str(e)
|
|
129
|
+
store.append(network_call)
|
|
130
|
+
raise e
|
|
131
|
+
|
|
132
|
+
httpx.AsyncClient.send = new_async_send
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def extract_auth(req):
|
|
138
|
+
token = None
|
|
139
|
+
scopes = None
|
|
140
|
+
user = None
|
|
141
|
+
method = "none"
|
|
142
|
+
|
|
143
|
+
params = getattr(req, "params", None)
|
|
144
|
+
meta = getattr(params, "meta", None) if params else None
|
|
145
|
+
if meta:
|
|
146
|
+
extra = getattr(meta, "model_extra", {}) or {}
|
|
147
|
+
auth = extra.get("auth") or extra.get("_meta", {}).get("auth")
|
|
148
|
+
if isinstance(auth, dict):
|
|
149
|
+
token = auth.get("token")
|
|
150
|
+
user = auth.get("user")
|
|
151
|
+
scopes_val = auth.get("scopes")
|
|
152
|
+
if isinstance(scopes_val, list):
|
|
153
|
+
scopes = [str(s) for s in scopes_val]
|
|
154
|
+
elif isinstance(scopes_val, str):
|
|
155
|
+
scopes = [scopes_val]
|
|
156
|
+
|
|
157
|
+
if not token:
|
|
158
|
+
authorization = extra.get("authorization")
|
|
159
|
+
if isinstance(authorization, str):
|
|
160
|
+
token = authorization
|
|
161
|
+
|
|
162
|
+
if token:
|
|
163
|
+
if isinstance(token, str) and token.lower().startswith("bearer "):
|
|
164
|
+
token = token[7:].strip()
|
|
165
|
+
method = "bearer"
|
|
166
|
+
else:
|
|
167
|
+
method = "token"
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
parts = token.split(".")
|
|
171
|
+
if len(parts) == 3:
|
|
172
|
+
payload = parts[1]
|
|
173
|
+
payload += "=" * ((4 - len(payload) % 4) % 4)
|
|
174
|
+
decoded = base64.urlsafe_b64decode(payload).decode("utf-8")
|
|
175
|
+
jwt_data = json.loads(decoded)
|
|
176
|
+
method = "jwt"
|
|
177
|
+
if not user:
|
|
178
|
+
user = jwt_data.get("sub") or jwt_data.get("uid") or jwt_data.get("email") or jwt_data.get("user") or jwt_data.get("name")
|
|
179
|
+
if not scopes:
|
|
180
|
+
claim_scopes = jwt_data.get("scope") or jwt_data.get("scopes") or jwt_data.get("permissions") or jwt_data.get("roles")
|
|
181
|
+
if isinstance(claim_scopes, list):
|
|
182
|
+
scopes = [str(s) for s in claim_scopes]
|
|
183
|
+
elif isinstance(claim_scopes, str):
|
|
184
|
+
scopes = claim_scopes.split()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
if not token and not user:
|
|
189
|
+
try:
|
|
190
|
+
local_user = getpass.getuser()
|
|
191
|
+
if local_user:
|
|
192
|
+
user = local_user
|
|
193
|
+
method = "local"
|
|
194
|
+
scopes = ["local:owner"]
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"token": token,
|
|
200
|
+
"scopes": scopes,
|
|
201
|
+
"user": user,
|
|
202
|
+
"method": method
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_process_memory():
|
|
207
|
+
try:
|
|
208
|
+
# Windows fallback using ctypes
|
|
209
|
+
if sys.platform == "win32":
|
|
210
|
+
import ctypes
|
|
211
|
+
from ctypes import wintypes
|
|
212
|
+
|
|
213
|
+
class PROCESS_MEMORY_COUNTERS(ctypes.Structure):
|
|
214
|
+
_fields_ = [
|
|
215
|
+
("cb", wintypes.DWORD),
|
|
216
|
+
("PageFaultCount", wintypes.DWORD),
|
|
217
|
+
("PeakWorkingSetSize", ctypes.c_size_t),
|
|
218
|
+
("WorkingSetSize", ctypes.c_size_t),
|
|
219
|
+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
|
220
|
+
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
|
221
|
+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
|
222
|
+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
|
223
|
+
("PagefileUsage", ctypes.c_size_t),
|
|
224
|
+
("PeakPagefileUsage", ctypes.c_size_t),
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
GetProcessMemoryInfo = ctypes.windll.psapi.GetProcessMemoryInfo
|
|
228
|
+
GetCurrentProcess = ctypes.windll.kernel32.GetCurrentProcess
|
|
229
|
+
|
|
230
|
+
counters = PROCESS_MEMORY_COUNTERS()
|
|
231
|
+
counters.cb = ctypes.sizeof(PROCESS_MEMORY_COUNTERS)
|
|
232
|
+
if GetProcessMemoryInfo(GetCurrentProcess(), ctypes.byref(counters), counters.cb):
|
|
233
|
+
return counters.WorkingSetSize
|
|
234
|
+
else:
|
|
235
|
+
# UNIX/Linux/macOS
|
|
236
|
+
import resource
|
|
237
|
+
rusage = resource.getrusage(resource.RUSAGE_SELF)
|
|
238
|
+
if sys.platform == "darwin":
|
|
239
|
+
return rusage.ru_maxrss
|
|
240
|
+
else:
|
|
241
|
+
return rusage.ru_maxrss * 1024
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def get_byte_size(val):
|
|
248
|
+
if val is None:
|
|
249
|
+
return 0
|
|
250
|
+
try:
|
|
251
|
+
if hasattr(val, "model_dump"):
|
|
252
|
+
data = val.model_dump()
|
|
253
|
+
elif hasattr(val, "dict"):
|
|
254
|
+
data = val.dict()
|
|
255
|
+
else:
|
|
256
|
+
data = val
|
|
257
|
+
return len(json.dumps(data).encode("utf-8"))
|
|
258
|
+
except Exception:
|
|
259
|
+
try:
|
|
260
|
+
return len(str(val).encode("utf-8"))
|
|
261
|
+
except Exception:
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def estimate_tokens(result, method):
|
|
266
|
+
if method != "tools/call" or not result:
|
|
267
|
+
return None
|
|
268
|
+
try:
|
|
269
|
+
total_text = ""
|
|
270
|
+
content = getattr(result, "content", None)
|
|
271
|
+
if content is None and hasattr(result, "root"):
|
|
272
|
+
content = getattr(result.root, "content", None)
|
|
273
|
+
|
|
274
|
+
if isinstance(content, list):
|
|
275
|
+
texts = []
|
|
276
|
+
for c in content:
|
|
277
|
+
if hasattr(c, "text"):
|
|
278
|
+
texts.append(getattr(c, "text") or "")
|
|
279
|
+
elif isinstance(c, dict):
|
|
280
|
+
texts.append(c.get("text") or "")
|
|
281
|
+
else:
|
|
282
|
+
texts.append(str(c))
|
|
283
|
+
total_text = " ".join(texts)
|
|
284
|
+
elif isinstance(result, dict):
|
|
285
|
+
total_text = json.dumps(result)
|
|
286
|
+
else:
|
|
287
|
+
total_text = str(result)
|
|
288
|
+
|
|
289
|
+
import math
|
|
290
|
+
return math.ceil(len(total_text) / 4)
|
|
291
|
+
except Exception:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def instrument(server, config):
|
|
296
|
+
setup_network_hooks()
|
|
297
|
+
transport = ScopeTransport(config)
|
|
298
|
+
|
|
299
|
+
capture_input = config.get("captureInput", True)
|
|
300
|
+
capture_output = config.get("captureOutput", True)
|
|
301
|
+
slow_threshold_ms = config.get("slowThresholdMs") or 1000
|
|
302
|
+
|
|
303
|
+
# ── Wrap low-level and FastMCP request processing ──
|
|
304
|
+
original_run = server.run
|
|
305
|
+
|
|
306
|
+
async def wrapped_run(*args, **kwargs):
|
|
307
|
+
# Dynamic interceptor mapping
|
|
308
|
+
import mcp.types as types
|
|
309
|
+
|
|
310
|
+
def wrap_handler(req_type, orig_handler):
|
|
311
|
+
async def wrapped_handler(req):
|
|
312
|
+
trace_start = time.perf_counter()
|
|
313
|
+
started_at = datetime.datetime.utcnow().isoformat() + "Z"
|
|
314
|
+
request_id = "req_" + str(uuid.uuid4())[:18]
|
|
315
|
+
|
|
316
|
+
# Context state setup
|
|
317
|
+
logs = []
|
|
318
|
+
net_calls = []
|
|
319
|
+
log_token_ctx = log_context.set(logs)
|
|
320
|
+
net_token_ctx = network_context.set(net_calls)
|
|
321
|
+
active_req_token = active_request.set(req)
|
|
322
|
+
|
|
323
|
+
method = getattr(req, "method", "unknown")
|
|
324
|
+
tool_name = None
|
|
325
|
+
input_payload = None
|
|
326
|
+
|
|
327
|
+
params = getattr(req, "params", None)
|
|
328
|
+
if params:
|
|
329
|
+
tool_name = getattr(params, "name", None)
|
|
330
|
+
arguments = getattr(params, "arguments", None)
|
|
331
|
+
if capture_input:
|
|
332
|
+
input_payload = arguments
|
|
333
|
+
|
|
334
|
+
auth_info = extract_auth(req)
|
|
335
|
+
|
|
336
|
+
# Measure CPU, memory usage start, and request size
|
|
337
|
+
cpu_start = time.process_time()
|
|
338
|
+
mem_start = get_process_memory()
|
|
339
|
+
request_size_bytes = get_byte_size(req)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
# Execute handler
|
|
343
|
+
enable_feedback = config.get("enableFeedback", True)
|
|
344
|
+
if enable_feedback and method == "tools/call" and tool_name == "send_feedback":
|
|
345
|
+
res = types.CallToolResult(
|
|
346
|
+
content=[
|
|
347
|
+
types.TextContent(
|
|
348
|
+
type="text",
|
|
349
|
+
text=json.dumps({
|
|
350
|
+
"success": True,
|
|
351
|
+
"message": "Thank you! Your feedback has been logged and shipped to the Scope dashboard."
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
],
|
|
355
|
+
isError=False
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
res = await orig_handler(req)
|
|
359
|
+
|
|
360
|
+
# Auto-inject send_feedback tool definition inside tools/list result
|
|
361
|
+
if enable_feedback and method == "tools/list" and res and hasattr(res, "tools") and isinstance(res.tools, list):
|
|
362
|
+
if not any(t.name == "send_feedback" for t in res.tools):
|
|
363
|
+
feedback_tool = types.Tool(
|
|
364
|
+
name="send_feedback",
|
|
365
|
+
description="Submit feedback or report an issue directly to the developers monitoring this MCP server.",
|
|
366
|
+
inputSchema={
|
|
367
|
+
"type": "object",
|
|
368
|
+
"properties": {
|
|
369
|
+
"message": {
|
|
370
|
+
"type": "string",
|
|
371
|
+
"description": "The feedback message, error context, or runtime issue detail."
|
|
372
|
+
},
|
|
373
|
+
"rating": {
|
|
374
|
+
"type": "integer",
|
|
375
|
+
"minimum": 1,
|
|
376
|
+
"maximum": 5,
|
|
377
|
+
"description": "Optional rating from 1 to 5."
|
|
378
|
+
},
|
|
379
|
+
"category": {
|
|
380
|
+
"type": "string",
|
|
381
|
+
"description": "Optional category, e.g., 'schema_issue', 'latency', 'incorrect_result', 'general'."
|
|
382
|
+
},
|
|
383
|
+
"tool_name": {
|
|
384
|
+
"type": "string",
|
|
385
|
+
"description": "Optional tool name this feedback relates to."
|
|
386
|
+
},
|
|
387
|
+
"request_id": {
|
|
388
|
+
"type": "string",
|
|
389
|
+
"description": "Optional trace request ID this feedback relates to."
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
"required": ["message"]
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
res.tools.append(feedback_tool)
|
|
396
|
+
|
|
397
|
+
duration_ms = int((time.perf_counter() - trace_start) * 1000)
|
|
398
|
+
ended_at = datetime.datetime.utcnow().isoformat() + "Z"
|
|
399
|
+
|
|
400
|
+
# Measure CPU, memory usage end, response size, and tokens
|
|
401
|
+
cpu_time_ms = int((time.process_time() - cpu_start) * 1000)
|
|
402
|
+
mem_used = max(0, get_process_memory() - mem_start)
|
|
403
|
+
response_size_bytes = get_byte_size(res)
|
|
404
|
+
response_tokens = estimate_tokens(res, method)
|
|
405
|
+
|
|
406
|
+
# Capture result
|
|
407
|
+
success = True
|
|
408
|
+
is_error = False
|
|
409
|
+
if res:
|
|
410
|
+
if hasattr(res, "isError"):
|
|
411
|
+
is_error = getattr(res, "isError", False)
|
|
412
|
+
elif hasattr(res, "root") and hasattr(res.root, "isError"):
|
|
413
|
+
is_error = getattr(res.root, "isError", False)
|
|
414
|
+
|
|
415
|
+
status = "success"
|
|
416
|
+
if is_error:
|
|
417
|
+
status = "error"
|
|
418
|
+
success = False
|
|
419
|
+
elif duration_ms >= slow_threshold_ms:
|
|
420
|
+
status = "slow"
|
|
421
|
+
|
|
422
|
+
# Format output representation
|
|
423
|
+
output_payload = None
|
|
424
|
+
if capture_output and res:
|
|
425
|
+
# Serialize Pydantic or basic types
|
|
426
|
+
try:
|
|
427
|
+
if hasattr(res, "model_dump"):
|
|
428
|
+
output_payload = res.model_dump()
|
|
429
|
+
else:
|
|
430
|
+
output_payload = res
|
|
431
|
+
except Exception:
|
|
432
|
+
output_payload = str(res)
|
|
433
|
+
|
|
434
|
+
error_obj = None
|
|
435
|
+
if is_error:
|
|
436
|
+
# Extract error string representation from content
|
|
437
|
+
err_msg = "Tool execution returned an error"
|
|
438
|
+
content = []
|
|
439
|
+
if res:
|
|
440
|
+
if hasattr(res, "content"):
|
|
441
|
+
content = getattr(res, "content", [])
|
|
442
|
+
elif hasattr(res, "root") and hasattr(res.root, "content"):
|
|
443
|
+
content = getattr(res.root, "content", [])
|
|
444
|
+
if isinstance(content, list) and len(content) > 0:
|
|
445
|
+
err_msg = getattr(content[0], "text", str(content[0]))
|
|
446
|
+
error_obj = {
|
|
447
|
+
"name": "ToolExecutionError",
|
|
448
|
+
"message": err_msg,
|
|
449
|
+
"code": "TOOL_ERROR",
|
|
450
|
+
"stack": None
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
event = {
|
|
454
|
+
"request_id": request_id,
|
|
455
|
+
"method": method,
|
|
456
|
+
"tool_name": tool_name,
|
|
457
|
+
"status": status,
|
|
458
|
+
"success": success,
|
|
459
|
+
"project": config.get("project"),
|
|
460
|
+
"environment": config.get("environment") or "development",
|
|
461
|
+
"server_name": config.get("serverName"),
|
|
462
|
+
"input": input_payload,
|
|
463
|
+
"output": output_payload,
|
|
464
|
+
"error": error_obj,
|
|
465
|
+
"console_logs": logs if logs else None,
|
|
466
|
+
"network_calls": net_calls if net_calls else None,
|
|
467
|
+
"started_at": started_at,
|
|
468
|
+
"ended_at": ended_at,
|
|
469
|
+
"duration_ms": duration_ms,
|
|
470
|
+
"auth_transport": config.get("transportType") or "stdio",
|
|
471
|
+
"auth_user": auth_info.get("user"),
|
|
472
|
+
"auth_scopes": auth_info.get("scopes"),
|
|
473
|
+
"auth_method": auth_info.get("method"),
|
|
474
|
+
"sdk_version": SDK_VERSION,
|
|
475
|
+
"sdk_language": SDK_LANGUAGE,
|
|
476
|
+
# Hardware & Sizing Metrics
|
|
477
|
+
"cpu_time_ms": cpu_time_ms,
|
|
478
|
+
"memory_used_bytes": mem_used,
|
|
479
|
+
"request_size_bytes": request_size_bytes,
|
|
480
|
+
"response_size_bytes": response_size_bytes,
|
|
481
|
+
"response_tokens": response_tokens
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
transport.send(event)
|
|
485
|
+
return res
|
|
486
|
+
except Exception as e:
|
|
487
|
+
duration_ms = int((time.perf_counter() - trace_start) * 1000)
|
|
488
|
+
ended_at = datetime.datetime.utcnow().isoformat() + "Z"
|
|
489
|
+
|
|
490
|
+
# Measure CPU and memory usage end
|
|
491
|
+
cpu_time_ms = int((time.process_time() - cpu_start) * 1000)
|
|
492
|
+
mem_used = max(0, get_process_memory() - mem_start)
|
|
493
|
+
|
|
494
|
+
error_obj = {
|
|
495
|
+
"name": type(e).__name__,
|
|
496
|
+
"message": str(e),
|
|
497
|
+
"code": getattr(e, "code", None),
|
|
498
|
+
"stack": "".join(traceback.format_exception(*sys.exc_info()))
|
|
499
|
+
}
|
|
500
|
+
response_size_bytes = get_byte_size(error_obj)
|
|
501
|
+
|
|
502
|
+
event = {
|
|
503
|
+
"request_id": request_id,
|
|
504
|
+
"method": method,
|
|
505
|
+
"tool_name": tool_name,
|
|
506
|
+
"status": "error",
|
|
507
|
+
"success": False,
|
|
508
|
+
"project": config.get("project"),
|
|
509
|
+
"environment": config.get("environment") or "development",
|
|
510
|
+
"server_name": config.get("serverName"),
|
|
511
|
+
"input": input_payload,
|
|
512
|
+
"output": None,
|
|
513
|
+
"error": error_obj,
|
|
514
|
+
"console_logs": logs if logs else None,
|
|
515
|
+
"network_calls": net_calls if net_calls else None,
|
|
516
|
+
"started_at": started_at,
|
|
517
|
+
"ended_at": ended_at,
|
|
518
|
+
"duration_ms": duration_ms,
|
|
519
|
+
"auth_transport": config.get("transportType") or "stdio",
|
|
520
|
+
"auth_user": auth_info.get("user"),
|
|
521
|
+
"auth_scopes": auth_info.get("scopes"),
|
|
522
|
+
"auth_method": auth_info.get("method"),
|
|
523
|
+
"sdk_version": SDK_VERSION,
|
|
524
|
+
"sdk_language": SDK_LANGUAGE,
|
|
525
|
+
# Hardware & Sizing Metrics
|
|
526
|
+
"cpu_time_ms": cpu_time_ms,
|
|
527
|
+
"memory_used_bytes": mem_used,
|
|
528
|
+
"request_size_bytes": request_size_bytes,
|
|
529
|
+
"response_size_bytes": response_size_bytes
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
transport.send(event)
|
|
533
|
+
raise e
|
|
534
|
+
finally:
|
|
535
|
+
log_context.reset(log_token_ctx)
|
|
536
|
+
network_context.reset(net_token_ctx)
|
|
537
|
+
active_request.reset(active_req_token)
|
|
538
|
+
return wrapped_handler
|
|
539
|
+
|
|
540
|
+
# Instrument all registered request handlers inside request_handlers dictionary
|
|
541
|
+
for req_cls, orig_handler in list(server.request_handlers.items()):
|
|
542
|
+
if req_cls in [types.CallToolRequest, types.ListToolsRequest, types.ListResourcesRequest, types.ReadResourceRequest, types.ListPromptsRequest, types.GetPromptRequest]:
|
|
543
|
+
server.request_handlers[req_cls] = wrap_handler(req_cls, orig_handler)
|
|
544
|
+
|
|
545
|
+
return await original_run(*args, **kwargs)
|
|
546
|
+
|
|
547
|
+
server.run = wrapped_run
|
|
548
|
+
return server
|
|
549
|
+
|
|
550
|
+
init = instrument
|
scope_sdk/transport.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import queue
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
import httpx
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
class ScopeTransport:
|
|
10
|
+
def __init__(self, config):
|
|
11
|
+
self.endpoint = config.get("endpoint") or "https://api.scope.gs/v1/ingest"
|
|
12
|
+
self.api_key = config.get("apiKey")
|
|
13
|
+
self.debug = config.get("debug", False)
|
|
14
|
+
self.max_batch_size = config.get("maxBatchSize") or 25
|
|
15
|
+
self.log_file = config.get("logFile")
|
|
16
|
+
|
|
17
|
+
self.queue = queue.Queue()
|
|
18
|
+
self.running = True
|
|
19
|
+
self.log_file_count = 0
|
|
20
|
+
|
|
21
|
+
# Initialize log file
|
|
22
|
+
if self.log_file:
|
|
23
|
+
try:
|
|
24
|
+
with open(self.log_file, "w", encoding="utf-8") as f:
|
|
25
|
+
f.write("[\n")
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Start worker thread
|
|
30
|
+
self.flush_interval = (config.get("flushIntervalMs") or 5000) / 1000.0
|
|
31
|
+
self.thread = threading.Thread(target=self._worker, daemon=True)
|
|
32
|
+
self.thread.start()
|
|
33
|
+
|
|
34
|
+
def send(self, event):
|
|
35
|
+
try:
|
|
36
|
+
# Debug logging
|
|
37
|
+
if self.debug:
|
|
38
|
+
status = "✓" if event.get("status") == "success" else "◎" if event.get("status") == "slow" else "✗"
|
|
39
|
+
dur = f"{event.get('duration_ms')}ms"
|
|
40
|
+
tool = f" {event.get('tool_name')}" if event.get("tool_name") else ""
|
|
41
|
+
sys_stderr = sys.stderr if 'sys' in globals() else None
|
|
42
|
+
if sys_stderr:
|
|
43
|
+
sys_stderr.write(f"[scope] {status} {dur.rjust(6)} {event.get('method')}{tool}\n")
|
|
44
|
+
sys_stderr.flush()
|
|
45
|
+
else:
|
|
46
|
+
print(f"[scope] {status} {dur.rjust(6)} {event.get('method')}{tool}", file=os.sys.stderr)
|
|
47
|
+
|
|
48
|
+
# Write to log file
|
|
49
|
+
if self.log_file:
|
|
50
|
+
try:
|
|
51
|
+
prefix = ",\n" if self.log_file_count > 0 else ""
|
|
52
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
53
|
+
f.write(prefix + json.dumps(event, indent=2))
|
|
54
|
+
self.log_file_count += 1
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
self.queue.put(event)
|
|
59
|
+
if self.queue.qsize() >= self.max_batch_size:
|
|
60
|
+
self.flush()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def flush(self):
|
|
65
|
+
# We put a special token in the queue to trigger immediate flush
|
|
66
|
+
self.queue.put("__FLUSH__")
|
|
67
|
+
|
|
68
|
+
def destroy(self):
|
|
69
|
+
self.running = False
|
|
70
|
+
self.flush()
|
|
71
|
+
try:
|
|
72
|
+
self.thread.join(timeout=2.0)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# Close the JSON array in log file
|
|
77
|
+
if self.log_file:
|
|
78
|
+
try:
|
|
79
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
80
|
+
f.write("\n]\n")
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def _worker(self):
|
|
85
|
+
last_flush = time.time()
|
|
86
|
+
while self.running or not self.queue.empty():
|
|
87
|
+
try:
|
|
88
|
+
timeout = max(0.1, self.flush_interval - (time.time() - last_flush))
|
|
89
|
+
item = self.queue.get(timeout=timeout)
|
|
90
|
+
except queue.Empty:
|
|
91
|
+
item = None
|
|
92
|
+
|
|
93
|
+
now = time.time()
|
|
94
|
+
if item == "__FLUSH__" or item is not None or (now - last_flush) >= self.flush_interval:
|
|
95
|
+
# Gather batch
|
|
96
|
+
batch = []
|
|
97
|
+
if item and item != "__FLUSH__":
|
|
98
|
+
batch.append(item)
|
|
99
|
+
|
|
100
|
+
# Drain rest of queue
|
|
101
|
+
while not self.queue.empty() and len(batch) < self.max_batch_size:
|
|
102
|
+
val = self.queue.get()
|
|
103
|
+
if val == "__FLUSH__":
|
|
104
|
+
break
|
|
105
|
+
batch.append(val)
|
|
106
|
+
|
|
107
|
+
if batch:
|
|
108
|
+
self._post(batch)
|
|
109
|
+
|
|
110
|
+
last_flush = time.time()
|
|
111
|
+
if item:
|
|
112
|
+
self.queue.task_done()
|
|
113
|
+
|
|
114
|
+
def _post(self, events):
|
|
115
|
+
try:
|
|
116
|
+
if self.debug:
|
|
117
|
+
print(f"[scope] flushing {len(events)} events → {self.endpoint}", file=os.sys.stderr)
|
|
118
|
+
|
|
119
|
+
headers = {
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
122
|
+
"X-Scope-SDK-Version": "0.1.0",
|
|
123
|
+
"X-Scope-SDK-Language": "python"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
with httpx.Client(timeout=10.0) as client:
|
|
127
|
+
res = client.post(self.endpoint, headers=headers, json={"events": events})
|
|
128
|
+
if res.status_code >= 400 and self.debug:
|
|
129
|
+
print(f"[scope] API returned {res.status_code}: {res.text}", file=os.sys.stderr)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
if self.debug:
|
|
132
|
+
print(f"[scope] transport error: {e}", file=os.sys.stderr)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scope-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for instrumenting MCP servers with Scope observability
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: mcp>=1.0.0
|
|
7
|
+
Requires-Dist: httpx>=0.20.0
|
|
8
|
+
Dynamic: requires-dist
|
|
9
|
+
Dynamic: requires-python
|
|
10
|
+
Dynamic: summary
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
scope_sdk/__init__.py,sha256=PERpHfLZahws2Mjn8mQFwPkFSDQrVz8DAf7xP1j72vs,105
|
|
2
|
+
scope_sdk/client.py,sha256=AJhNkON7UXA0kfQzzAumHBk-D3ThCYm462p8alARO5g,23384
|
|
3
|
+
scope_sdk/transport.py,sha256=N1BJq4P4pZJor5DChkRrjBX3Qm-Ty7gN2MdYKYDwFkU,4853
|
|
4
|
+
scope_sdk-0.1.0.dist-info/METADATA,sha256=rKJip9kGCtYb8ehIOydJohjtAVLKjSKo43dk0fwE-2s,282
|
|
5
|
+
scope_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
scope_sdk-0.1.0.dist-info/top_level.txt,sha256=jdnz4EO_XhOOpv-tB6jC_XBfVrY8entjbNGxt_aObSw,10
|
|
7
|
+
scope_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scope_sdk
|