raindrop-mcp 0.0.1__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.
@@ -0,0 +1,6 @@
1
+ """Raindrop instrumentation for Python MCP servers."""
2
+
3
+ from .wrapper import INTENT, TURN_ID, McpCallContext, McpClientInfo, RaindropMcp, create_raindrop_mcp
4
+
5
+ __version__ = "0.0.1"
6
+ __all__ = ["RaindropMcp", "create_raindrop_mcp", "McpCallContext", "McpClientInfo", "TURN_ID", "INTENT"]
raindrop_mcp/py.typed ADDED
File without changes
@@ -0,0 +1,1185 @@
1
+ """Raindrop instrumentation for Python MCP servers.
2
+
3
+ Works with ``mcp`` 1.x (``FastMCP``) and ``mcp`` 2.x (``MCPServer``). The one
4
+ seam both share is the server's ``ToolManager``: ``add_tool`` registers every
5
+ tool and ``call_tool`` runs every call, so wrapping those two methods
6
+ instruments any server without touching its tools.
7
+
8
+ from raindrop_mcp import RaindropMcp
9
+
10
+ raindrop = RaindropMcp(api_key=os.environ["RAINDROP_API_KEY"])
11
+ server = raindrop.wrap(FastMCP("my-server"))
12
+
13
+ Every tool call becomes a tool span on an ``mcp_activity`` event. MCP has no
14
+ turn boundary, so with ``turn_tracking`` (the default) every tool advertises an
15
+ optional ``_turn_id`` that is appended to each result; the model copies it on
16
+ its next call and the calls of one prompt land on one event. Every tool also
17
+ advertises an optional ``_intent`` the model fills with what the user wants on
18
+ the first call of a prompt, and the calling MCP client is recorded on every call.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextvars
24
+ import hashlib
25
+ import inspect
26
+ import logging
27
+ import re
28
+ import time
29
+ import uuid
30
+ import warnings
31
+ from dataclasses import dataclass, replace
32
+ from datetime import datetime, timezone
33
+ from typing import (
34
+ Any,
35
+ Awaitable,
36
+ Callable,
37
+ Dict,
38
+ List,
39
+ Literal,
40
+ Mapping,
41
+ Optional,
42
+ Set,
43
+ Tuple,
44
+ Union,
45
+ )
46
+
47
+ import raindrop.analytics as raindrop
48
+ from raindrop import Raindrop
49
+
50
+ logger = logging.getLogger("raindrop_mcp")
51
+
52
+ TURN_ID = "_turn_id"
53
+ """The optional argument every tool advertises and every result echoes."""
54
+ INTENT = "_intent"
55
+ """What the user asked for, in the model's words; filled on the first call of a prompt."""
56
+
57
+ _DESCRIPTIONS: Dict[str, str] = {
58
+ TURN_ID: (
59
+ "Copy from the most recent tool result in this turn. Omit on the first tool "
60
+ "call of a new user prompt; never reuse an id from an earlier prompt."
61
+ ),
62
+ INTENT: (
63
+ "One sentence on what the user is trying to accomplish. Fill in on the first "
64
+ "tool call of a new user prompt; omit on follow-up calls."
65
+ ),
66
+ }
67
+ _TURN_ID_PATTERN = re.compile(r"^[a-f0-9]{16}$")
68
+ _INTENT_MAX_CHARS = 1_000
69
+ _AI_INVOCATION_META = "io.modelcontextprotocol/aiInvocation"
70
+ _CLIENT_INFO_META = "io.modelcontextprotocol/clientInfo"
71
+ _SESSION_ID_HEADER = "mcp-session-id"
72
+ # Serverless hosts freeze after the response, so a slow ship is dropped rather than awaited forever.
73
+ _SHIP_DEADLINE_S = 5.0
74
+ _DEFAULT_MAX_TEXT_FIELD_CHARS = 1_000_000
75
+ _REDACTED = "[REDACTED]"
76
+ _WRAPPED_ATTR = "_raindrop_mcp_wrapped"
77
+ _MAX_META_EVENTS = 10_000
78
+ # Same list as @raindrop-ai/core's redactSecretsInObject, matched on lowercased names without -_. separators.
79
+ _SECRET_KEY_NAMES = frozenset(
80
+ {
81
+ "apikey", "apisecret", "apitoken", "secretaccesskey", "sessiontoken",
82
+ "privatekey", "privatekeyid", "clientsecret", "accesstoken", "refreshtoken",
83
+ "oauthtoken", "bearertoken", "authorization", "password", "passphrase",
84
+ }
85
+ )
86
+
87
+
88
+ @dataclass
89
+ class McpClientInfo:
90
+ """The MCP client (``clientInfo``), e.g. ``name="claude-code", version="2.1.0"``."""
91
+
92
+ name: str
93
+ version: Optional[str] = None
94
+
95
+
96
+ @dataclass
97
+ class McpCallContext:
98
+ """What the server handed the tool, passed to the ``user_id`` / ``convo_id`` / ``properties`` callables."""
99
+
100
+ tool_name: str
101
+ args: Dict[str, Any]
102
+ """Tool arguments as the tool received them, without the injected fields."""
103
+ session_id: Optional[str]
104
+ """The ``mcp-session-id`` of the request, when the transport has one."""
105
+ context: Any
106
+ """The SDK's per-request ``Context`` (``None`` when the server passed none)."""
107
+ client: Optional[McpClientInfo] = None
108
+ """The MCP client that made the call, when known."""
109
+ intent: Optional[str] = None
110
+ """What the model said the user wants, when it filled ``_intent`` on this call."""
111
+
112
+
113
+ Resolver = Callable[[McpCallContext], Optional[str]]
114
+ PropertiesResolver = Callable[[McpCallContext], Optional[Dict[str, Any]]]
115
+
116
+
117
+ @dataclass
118
+ class _Turn:
119
+ id: str
120
+ is_new: bool
121
+ """No usable ``_turn_id`` was echoed, so this call opens a new event."""
122
+ source: Literal["echo", "meta"]
123
+ """``meta``: the client sent the id per request, so there is nothing to echo."""
124
+ args: Dict[str, Any]
125
+ """The arguments without the injected fields, as the tool expects them."""
126
+ intent: Optional[str] = None
127
+ """What the model said the user wants, when it filled ``_intent`` on this call."""
128
+
129
+
130
+ def _new_turn(raw_args: Any) -> _Turn:
131
+ args = dict(raw_args) if isinstance(raw_args, Mapping) else {}
132
+ return _Turn(id=uuid.uuid4().hex[:16], is_new=True, source="echo", args=args)
133
+
134
+
135
+ def _intent_of(value: Any) -> Optional[str]:
136
+ if not isinstance(value, str):
137
+ return None
138
+ return value.strip()[:_INTENT_MAX_CHARS] or None
139
+
140
+
141
+ def _split_turn(raw_args: Any, fields: Tuple[str, ...] = (TURN_ID,)) -> _Turn:
142
+ """Splits the injected fields off the arguments; a missing or malformed ``_turn_id`` means a new turn."""
143
+ args = dict(raw_args) if isinstance(raw_args, Mapping) else {}
144
+ values = {field: args.pop(field) for field in fields if field in args}
145
+ echoed = values.get(TURN_ID)
146
+ if isinstance(echoed, str) and _TURN_ID_PATTERN.match(echoed):
147
+ turn = _Turn(id=echoed, is_new=False, source="echo", args=args)
148
+ else:
149
+ turn = _new_turn(args)
150
+ turn.intent = _intent_of(values.get(INTENT))
151
+ return turn
152
+
153
+
154
+ def _text_block(text: str) -> Any:
155
+ from mcp.types import TextContent
156
+
157
+ return TextContent(type="text", text=text)
158
+
159
+
160
+ def _echo_turn_id(result: Any, turn_id: str) -> Any:
161
+ """Appends the turn id to a tool result so the model echoes it on its next call."""
162
+ echo = _text_block(f"{TURN_ID}: {turn_id}")
163
+ content = getattr(result, "content", None)
164
+ if isinstance(content, list) and not isinstance(result, (list, tuple)):
165
+ # 2.x: a CallToolResult (also what a 1.x tool returns when it builds its own).
166
+ result.content = [*content, echo]
167
+ return result
168
+ if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], (list, tuple)):
169
+ # 1.x structured output: (unstructured content, structured content).
170
+ return ([*result[0], echo], result[1])
171
+ if isinstance(result, list):
172
+ # 1.x unstructured content.
173
+ return [*result, echo]
174
+ return result
175
+
176
+
177
+ def _error_result(message: str, turn_id: str, *, standalone: bool = False) -> Any:
178
+ """The ``isError`` result the SDK would have built from the exception, plus the echo."""
179
+ content = [_text_block(message), _text_block(f"{TURN_ID}: {turn_id}")]
180
+ if standalone:
181
+ if not _standalone_has_error_results():
182
+ raise TypeError("this fastmcp has no error results; the exception propagates instead")
183
+ return _standalone_tool_result_cls()(content=content, is_error=True)
184
+ from mcp.types import CallToolResult
185
+
186
+ error_field = "is_error" if "is_error" in CallToolResult.model_fields else "isError"
187
+ return CallToolResult(content=content, **{error_field: True})
188
+
189
+
190
+ def _standalone_tool_result_cls() -> Any:
191
+ """Standalone fastmcp's ``ToolResult`` (moved between its 2.x and 3.x releases)."""
192
+ try:
193
+ from fastmcp.tools import ToolResult # type: ignore[import-not-found]
194
+ except ImportError:
195
+ from fastmcp.tools.tool import ToolResult # type: ignore[import-not-found]
196
+ return ToolResult
197
+
198
+
199
+ def _standalone_has_error_results() -> bool:
200
+ """fastmcp 2.x's ``ToolResult`` can't carry ``is_error``; a failing tool there propagates instead of echoing."""
201
+ try:
202
+ return "is_error" in inspect.signature(_standalone_tool_result_cls()).parameters
203
+ except Exception:
204
+ return False
205
+
206
+
207
+ def _protocol_errors() -> Tuple[type, ...]:
208
+ """JSON-RPC level errors the server must answer as protocol errors, never as tool results."""
209
+ errors: List[type] = []
210
+ try:
211
+ from mcp.shared import exceptions as mcp_exceptions
212
+
213
+ for name in ("MCPError", "McpError"):
214
+ error = getattr(mcp_exceptions, name, None)
215
+ if inspect.isclass(error):
216
+ errors.append(error)
217
+ except Exception:
218
+ pass
219
+ try:
220
+ from fastmcp.exceptions import NotFoundError # type: ignore[import-not-found]
221
+
222
+ errors.append(NotFoundError) # standalone fastmcp: unknown tool name
223
+ except Exception:
224
+ pass
225
+ return tuple(errors)
226
+
227
+
228
+ def _error_message(error: BaseException) -> str:
229
+ message = str(error) or type(error).__name__
230
+ cause = error.__cause__
231
+ # 2.x hides a crashed tool's text from the client; keep it for telemetry.
232
+ if cause is not None and str(cause) and str(cause) not in message:
233
+ message = f"{message}: {cause}"
234
+ return message
235
+
236
+
237
+ def _result_parts(result: Any) -> Tuple[List[Any], Any, bool]:
238
+ """``(content blocks, structured content, is_error)`` for every result shape the SDKs produce."""
239
+ if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], (list, tuple)):
240
+ return list(result[0]), result[1], False
241
+ if isinstance(result, list):
242
+ return result, None, False
243
+ content = getattr(result, "content", None)
244
+ if isinstance(content, list):
245
+ structured = getattr(result, "structured_content", None)
246
+ if structured is None:
247
+ structured = getattr(result, "structuredContent", None)
248
+ is_error = getattr(result, "is_error", None)
249
+ if is_error is None:
250
+ is_error = getattr(result, "isError", None)
251
+ return content, structured, is_error is True
252
+ if isinstance(result, Mapping):
253
+ return [], result, False
254
+ return [], result, False
255
+
256
+
257
+ def _is_error_result(result: Any) -> bool:
258
+ try:
259
+ return _result_parts(result)[2]
260
+ except Exception:
261
+ return False
262
+
263
+
264
+ def _tool_result_text(result: Any, limit: int) -> str:
265
+ """Text a model would read from a tool result; binary blocks become stubs, no text falls back to structured content."""
266
+ content, structured, _ = _result_parts(result)
267
+ parts: List[str] = []
268
+ has_text = False
269
+ for block in content:
270
+ block_type = getattr(block, "type", None)
271
+ if block_type is None and isinstance(block, Mapping):
272
+ block_type = block.get("type")
273
+ if not isinstance(block_type, str):
274
+ continue
275
+ text = getattr(block, "text", None)
276
+ if text is None and isinstance(block, Mapping):
277
+ text = block.get("text")
278
+ if block_type == "text" and isinstance(text, str):
279
+ has_text = True
280
+ parts.append(text)
281
+ else:
282
+ parts.append(f"[{block_type}]")
283
+ if not has_text and structured is not None:
284
+ return _dumps(structured, limit)
285
+ return "\n".join(parts)[:limit]
286
+
287
+
288
+ def _dumps(value: Any, limit: int) -> str:
289
+ try:
290
+ import json
291
+
292
+ return json.dumps(value, default=str)[:limit]
293
+ except Exception:
294
+ try:
295
+ return str(value)[:limit]
296
+ except Exception:
297
+ return "[unserializable]"
298
+
299
+
300
+ def _normalize_key(name: str) -> str:
301
+ return re.sub(r"[-_.]", "", name.lower())
302
+
303
+
304
+ def _redact_secrets(value: Any, budget: List[int], limit: int, depth: int = 0) -> Any:
305
+ """Replaces values under credential-looking keys and caps string leaves.
306
+
307
+ Bounded so a huge payload costs the budget, not the payload; whatever the budget
308
+ cannot visit is dropped rather than forwarded unredacted.
309
+ """
310
+ budget[0] -= 1
311
+ if budget[0] < 0 or depth > 16:
312
+ return "[truncated]"
313
+ if isinstance(value, Mapping):
314
+ return {
315
+ str(key): (
316
+ _REDACTED
317
+ if _normalize_key(str(key)) in _SECRET_KEY_NAMES
318
+ else _redact_secrets(item, budget, limit, depth + 1)
319
+ )
320
+ for key, item in value.items()
321
+ }
322
+ if isinstance(value, (list, tuple)):
323
+ return [_redact_secrets(item, budget, limit, depth + 1) for item in value]
324
+ if isinstance(value, str) and len(value) > limit:
325
+ return value[:limit]
326
+ return value
327
+
328
+
329
+ def _headers_of(ctx: Any) -> Optional[Mapping[str, Any]]:
330
+ try:
331
+ headers = getattr(ctx, "headers", None)
332
+ if headers is None:
333
+ request_context = getattr(ctx, "request_context", None)
334
+ headers = getattr(getattr(request_context, "request", None), "headers", None)
335
+ return headers if isinstance(headers, Mapping) else None
336
+ except Exception:
337
+ return None
338
+
339
+
340
+ def _header(ctx: Any, name: str) -> Optional[str]:
341
+ headers = _headers_of(ctx)
342
+ if headers is None:
343
+ return None
344
+ try:
345
+ value = headers.get(name)
346
+ if value is None:
347
+ for key, candidate in headers.items():
348
+ if isinstance(key, str) and key.lower() == name:
349
+ value = candidate
350
+ break
351
+ return value if isinstance(value, str) else None
352
+ except Exception:
353
+ return None
354
+
355
+
356
+ def _session_id_of(ctx: Any) -> Optional[str]:
357
+ session_id = _header(ctx, _SESSION_ID_HEADER)
358
+ if session_id is None and _is_standalone_fastmcp(ctx):
359
+ # Standalone fastmcp keeps its own per-connection id on the Context (stdio has no header);
360
+ # the property raises outside a client request (server-side call_tool).
361
+ try:
362
+ session_id = ctx.session_id
363
+ except Exception:
364
+ session_id = None
365
+ if not isinstance(session_id, str):
366
+ return None
367
+ session_id = session_id.strip()[:128]
368
+ return session_id or None
369
+
370
+
371
+ def _access_token() -> Any:
372
+ try:
373
+ from mcp.server.auth.middleware.auth_context import get_access_token
374
+
375
+ return get_access_token()
376
+ except Exception:
377
+ return None
378
+
379
+
380
+ def _bearer_token_of(ctx: Any, access_token: Any) -> Optional[str]:
381
+ token = getattr(access_token, "token", None)
382
+ if isinstance(token, str) and token:
383
+ return token
384
+ header = _header(ctx, "authorization")
385
+ if header is None:
386
+ return None
387
+ match = re.match(r"^Bearer\s+(.+)$", header, re.IGNORECASE)
388
+ return match.group(1).strip() if match else None
389
+
390
+
391
+ def _default_user_id(ctx: Any) -> str:
392
+ """OAuth subject, else a hash of the bearer credential (never the raw token), else the transport session, else anonymous."""
393
+ access_token = _access_token()
394
+ subject = getattr(access_token, "subject", None)
395
+ if isinstance(subject, str) and subject:
396
+ return subject
397
+ token = _bearer_token_of(ctx, access_token)
398
+ if token:
399
+ return f"token:{hashlib.sha256(token.encode()).hexdigest()[:32]}"
400
+ session_id = _session_id_of(ctx)
401
+ return f"session:{session_id}" if session_id else "anonymous"
402
+
403
+
404
+ def _meta_of(ctx: Any) -> Any:
405
+ meta = getattr(ctx, "meta", None)
406
+ if meta is None:
407
+ request_context = getattr(ctx, "request_context", None)
408
+ meta = getattr(request_context, "meta", None)
409
+ return meta
410
+
411
+
412
+ def _meta_turn_of(ctx: Any, args: Dict[str, Any]) -> Optional[_Turn]:
413
+ """Turn ids some clients already send per request (SEP-2817); nothing to echo when present."""
414
+ try:
415
+ meta = _meta_of(ctx)
416
+ if meta is None:
417
+ return None
418
+ if isinstance(meta, Mapping):
419
+ invocation = meta.get(_AI_INVOCATION_META)
420
+ else:
421
+ extra = getattr(meta, "model_extra", None) or {}
422
+ invocation = extra.get(_AI_INVOCATION_META, getattr(meta, _AI_INVOCATION_META, None))
423
+ if not isinstance(invocation, Mapping):
424
+ return None
425
+ turn_id = invocation.get("turnId")
426
+ if not isinstance(turn_id, str) or not turn_id.strip():
427
+ return None
428
+ return _Turn(id=turn_id.strip()[:128], is_new=True, source="meta", args=args)
429
+ except Exception:
430
+ return None
431
+
432
+
433
+ def _with_meta_turn(ctx: Any, turn: _Turn) -> _Turn:
434
+ meta_turn = _meta_turn_of(ctx, turn.args)
435
+ return replace(meta_turn, intent=turn.intent) if meta_turn is not None else turn
436
+
437
+
438
+ def _client_info_from(value: Any) -> Optional[McpClientInfo]:
439
+ if isinstance(value, Mapping):
440
+ name, version = value.get("name"), value.get("version")
441
+ else:
442
+ name, version = getattr(value, "name", None), getattr(value, "version", None)
443
+ if not isinstance(name, str) or not name.strip():
444
+ return None
445
+ info = McpClientInfo(name=name.strip()[:128])
446
+ if isinstance(version, str) and version.strip():
447
+ info.version = version.strip()[:64]
448
+ return info
449
+
450
+
451
+ def _client_info_of(ctx: Any) -> Optional[McpClientInfo]:
452
+ """Who is calling: the per-request ``clientInfo`` 2026-era clients send in ``_meta``, else what ``initialize`` said."""
453
+ try:
454
+ meta = _meta_of(ctx)
455
+ if isinstance(meta, Mapping):
456
+ info = _client_info_from(meta.get(_CLIENT_INFO_META))
457
+ elif meta is not None:
458
+ extra = getattr(meta, "model_extra", None) or {}
459
+ info = _client_info_from(extra.get(_CLIENT_INFO_META))
460
+ else:
461
+ info = None
462
+ if info is not None:
463
+ return info
464
+ except Exception:
465
+ pass
466
+ for path in (("connection",), ("session",), ("request_context", "session")):
467
+ try:
468
+ holder: Any = ctx
469
+ for attr in path:
470
+ holder = getattr(holder, attr)
471
+ params = holder.client_params
472
+ client = getattr(params, "client_info", None)
473
+ if client is None:
474
+ client = getattr(params, "clientInfo", None)
475
+ info = _client_info_from(client)
476
+ if info is not None:
477
+ return info
478
+ except Exception:
479
+ continue
480
+ return None
481
+
482
+
483
+ def _session_input(intent: Optional[str]) -> str:
484
+ """Not user text, so it is labelled as what it is; the intent is the model's one-liner."""
485
+ return "MCP session started" if intent is None else f"MCP session started\nSession intent:\n{intent}"
486
+
487
+
488
+ def _collision_warning(tool_name: str, field: str) -> str:
489
+ """Written for whoever (or whatever agent) maintains the tool: what is lost and the two ways to fix it."""
490
+ if field == INTENT:
491
+ purpose, loss, option, feature = (
492
+ "the user's stated intent",
493
+ "intent will NOT be captured for this tool",
494
+ "intent=False",
495
+ "intent capture",
496
+ )
497
+ else:
498
+ purpose, loss, option, feature = (
499
+ "grouping one prompt's tool calls into one event",
500
+ "each call to this tool will be its own event",
501
+ "turn_tracking=False",
502
+ "turn grouping",
503
+ )
504
+ return (
505
+ f'[raindrop-mcp] Tool "{tool_name}" already has an argument named "{field}". '
506
+ f'raindrop-mcp reserves "{field}" for {purpose}, so {loss}. '
507
+ f'Fix: rename the tool\'s "{field}" argument (any other name works), or set `{option}` '
508
+ f"in RaindropMcp() to disable {feature} for every tool."
509
+ )
510
+
511
+
512
+
513
+
514
+ def _attach_turn_trace(digest: bytes) -> Any:
515
+ """Puts a turn-level parent in the OTel context so every span of the turn joins one trace."""
516
+ try:
517
+ from opentelemetry import context as otel_context
518
+ from opentelemetry import trace
519
+
520
+ span_context = trace.SpanContext(
521
+ trace_id=int.from_bytes(digest[16:32], "big"),
522
+ span_id=int.from_bytes(digest[8:16], "big"),
523
+ is_remote=True,
524
+ trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
525
+ )
526
+ return otel_context.attach(trace.set_span_in_context(trace.NonRecordingSpan(span_context)))
527
+ except Exception:
528
+ return None
529
+
530
+
531
+ def _detach_turn_trace(token: Any) -> None:
532
+ if token is None:
533
+ return
534
+ try:
535
+ from opentelemetry import context as otel_context
536
+
537
+ otel_context.detach(token)
538
+ except Exception:
539
+ pass
540
+
541
+
542
+ def _manager_class_for(server_cls: type) -> Optional[type]:
543
+ for base in inspect.getmro(server_cls):
544
+ module = getattr(base, "__module__", "") or ""
545
+ if base.__name__ == "ToolManager" and hasattr(base, "add_tool") and hasattr(base, "call_tool"):
546
+ return base
547
+ if module.startswith("mcp.server.mcpserver"):
548
+ from mcp.server.mcpserver.tools import ToolManager
549
+
550
+ return ToolManager
551
+ if module.startswith("mcp.server.fastmcp"):
552
+ from mcp.server.fastmcp.tools import ToolManager
553
+
554
+ return ToolManager
555
+ if module.startswith("fastmcp."):
556
+ # Standalone fastmcp 2.x keeps a ToolManager; 3.x+ has none (see _standalone_targets).
557
+ try:
558
+ from fastmcp.tools.tool_manager import ToolManager # type: ignore[import-not-found]
559
+ except ImportError:
560
+ return None
561
+ return ToolManager if hasattr(ToolManager, "call_tool") else None
562
+ return None
563
+
564
+
565
+ def _is_standalone_fastmcp(server: Any) -> bool:
566
+ cls = server if inspect.isclass(server) else type(server)
567
+ return any((getattr(base, "__module__", "") or "").startswith("fastmcp.") for base in inspect.getmro(cls))
568
+
569
+
570
+ # Standalone fastmcp 3.x+ opens its Context inside call_tool and re-enters
571
+ # call_tool(run_middleware=False) from the middleware chain. The outer call owns
572
+ # the turn (middleware may answer from a cache without ever reaching the inner
573
+ # call); the inner one hands its Context back through this holder.
574
+ _inner_ctx: contextvars.ContextVar[Optional[List[Any]]] = contextvars.ContextVar("raindrop_mcp_inner_ctx", default=None)
575
+
576
+
577
+ def _standalone_context() -> Any:
578
+ """The current standalone fastmcp ``Context`` (its ``call_tool`` takes no context argument)."""
579
+ try:
580
+ from fastmcp.server.dependencies import get_context # type: ignore[import-not-found]
581
+
582
+ return get_context()
583
+ except Exception:
584
+ return None
585
+
586
+
587
+ def _standalone_targets(server: Any) -> Optional[Tuple[Any, Any]]:
588
+ """``(call target, registration target)`` for standalone ``fastmcp`` 3.x+ servers.
589
+
590
+ Those keep ``call_tool`` on the server itself and register tools through a
591
+ ``LocalProvider`` (``@mcp.tool`` and ``mcp.add_tool`` both end in its ``add_tool``).
592
+ """
593
+ if not _is_standalone_fastmcp(server) or not hasattr(server, "call_tool"):
594
+ return None
595
+ if inspect.isclass(server):
596
+ try:
597
+ from fastmcp.server.providers import LocalProvider # type: ignore[import-not-found]
598
+ except ImportError:
599
+ return None
600
+ return server, LocalProvider
601
+ provider = getattr(server, "_local_provider", None)
602
+ if provider is None or not hasattr(provider, "add_tool"):
603
+ return None
604
+ return server, provider
605
+
606
+
607
+ def _registered_tools(target: Any) -> List[Any]:
608
+ tools = getattr(target, "_tools", None)
609
+ if isinstance(tools, Mapping):
610
+ return list(tools.values())
611
+ components = getattr(target, "_components", None)
612
+ if isinstance(components, Mapping):
613
+ return [tool for tool in components.values() if hasattr(tool, "parameters")]
614
+ return []
615
+
616
+
617
+ class RaindropMcp:
618
+ """Instruments MCP servers so every tool call lands in Raindrop.
619
+
620
+ Args:
621
+ api_key: Raindrop write key. Omitting it disables shipping (a warning is
622
+ emitted); tools still get ``_turn_id`` so behavior is identical.
623
+ user_id: Who made the call: a fixed string or a callable of
624
+ :class:`McpCallContext` returning the id (``None`` skips the call).
625
+ Defaults to the OAuth subject, else a hash of the bearer token,
626
+ else ``session:<mcp-session-id>``, else ``anonymous``.
627
+ convo_id: Conversation grouping, fixed or callable. Defaults to the
628
+ transport session, else the caller's UTC day.
629
+ event_name: Event name. Defaults to ``mcp_activity``.
630
+ turn_tracking: Advertise ``_turn_id`` on every tool and append it to
631
+ every result so one prompt's calls share one event. ``False`` leaves
632
+ tool schemas and results untouched (and disables ``intent``); every
633
+ call is its own event.
634
+ intent: Advertise ``_intent`` on every tool, which the model fills with a
635
+ one-sentence description of what the user wants on the first call of
636
+ a prompt. Recorded as ``mcp.intent`` and appended to the event input.
637
+ properties: Extra properties on every event, a dict or a callable.
638
+ redact_arguments: Argument names never captured, on top of the default
639
+ credential-key redaction.
640
+ redact_results: Tools whose results are never captured.
641
+ await_delivery: Wait (at most 5s, off the event loop) for delivery
642
+ before returning each tool result. Needed on serverless. Default True.
643
+ max_text_field_chars: Per-field cap for captured arguments and results.
644
+ tracing_enabled / bypass_otel_for_tools: Forwarded to the Raindrop client;
645
+ tool spans need both (the defaults).
646
+ client: An existing :class:`raindrop.Raindrop` to ship through instead.
647
+ """
648
+
649
+ def __init__(
650
+ self,
651
+ api_key: Optional[str] = None,
652
+ user_id: Union[str, Resolver, None] = None,
653
+ convo_id: Union[str, Resolver, None] = None,
654
+ tracing_enabled: bool = True,
655
+ bypass_otel_for_tools: bool = True,
656
+ debug: bool = False,
657
+ *,
658
+ event_name: str = "mcp_activity",
659
+ turn_tracking: bool = True,
660
+ intent: bool = True,
661
+ properties: Union[Dict[str, Any], PropertiesResolver, None] = None,
662
+ redact_arguments: Optional[List[str]] = None,
663
+ redact_results: Optional[List[str]] = None,
664
+ await_delivery: bool = True,
665
+ max_text_field_chars: Optional[int] = None,
666
+ project_id: Optional[str] = None,
667
+ endpoint: Optional[str] = None,
668
+ app_git: Any = True,
669
+ client: Optional[Raindrop] = None,
670
+ ) -> None:
671
+ if debug:
672
+ logger.setLevel(logging.DEBUG)
673
+ self._user_id = user_id
674
+ self._convo_id = convo_id
675
+ self._properties = properties
676
+ self._event_name = event_name or "mcp_activity"
677
+ self._turn_tracking = bool(turn_tracking)
678
+ self._intent = bool(intent)
679
+ self._redact_arguments: Set[str] = set(redact_arguments or ())
680
+ self._redact_results: Set[str] = set(redact_results or ())
681
+ self._await_delivery = bool(await_delivery)
682
+ self._max_text_field_chars = (
683
+ int(max_text_field_chars)
684
+ if isinstance(max_text_field_chars, int) and max_text_field_chars > 0
685
+ else _DEFAULT_MAX_TEXT_FIELD_CHARS
686
+ )
687
+ self._opened_meta_events: Set[str] = set()
688
+ self._injected_tools: Dict[int, Tuple[str, ...]] = {}
689
+ self._owned_fields: Dict[str, Set[str]] = {}
690
+ self._rd: Optional[Raindrop] = None
691
+
692
+ key = (api_key or "").strip()
693
+ if client is not None:
694
+ self._rd = client
695
+ elif not key:
696
+ warnings.warn(
697
+ "[raindrop-mcp] api_key not provided; telemetry shipping is disabled",
698
+ stacklevel=2,
699
+ )
700
+ else:
701
+ try:
702
+ self._rd = Raindrop(
703
+ api_key=key,
704
+ tracing_enabled=tracing_enabled,
705
+ bypass_otel_for_tools=bypass_otel_for_tools,
706
+ auto_instrument=False,
707
+ project_id=project_id,
708
+ app_git=app_git,
709
+ max_text_field_chars=max_text_field_chars,
710
+ **({"endpoint": endpoint} if endpoint is not None else {}),
711
+ )
712
+ except Exception:
713
+ logger.debug("Failed to configure Raindrop client", exc_info=True)
714
+ warnings.warn("[raindrop-mcp] telemetry disabled; see debug logs", stacklevel=2)
715
+
716
+ # -- wrapping ----------------------------------------------------------- #
717
+
718
+ def wrap(self, server: Any) -> Any:
719
+ """Instruments a ``FastMCP`` / ``MCPServer`` instance, including tools it already has.
720
+
721
+ Pass the class (``raindrop.wrap(FastMCP)``) to instrument every server a
722
+ third-party package constructs afterwards. Returns ``server`` unchanged.
723
+ """
724
+ try:
725
+ if inspect.isclass(server):
726
+ manager_cls = _manager_class_for(server)
727
+ if manager_cls is not None:
728
+ if _WRAPPED_ATTR not in vars(manager_cls):
729
+ self._install(manager_cls, manager_cls, is_class=True)
730
+ return server
731
+ targets = _standalone_targets(server)
732
+ if targets is None:
733
+ raise TypeError("wrap() expects a FastMCP / MCPServer instance or class")
734
+ call_target, add_target = targets
735
+ if _WRAPPED_ATTR not in vars(call_target):
736
+ self._install(call_target, add_target, is_class=True)
737
+ return server
738
+ manager = getattr(server, "_tool_manager", None)
739
+ if manager is not None and hasattr(manager, "add_tool") and hasattr(manager, "call_tool"):
740
+ call_target = add_target = manager
741
+ else:
742
+ targets = _standalone_targets(server)
743
+ if targets is None:
744
+ raise TypeError("wrap() expects a FastMCP / MCPServer instance or class")
745
+ call_target, add_target = targets
746
+ if _WRAPPED_ATTR in vars(call_target) or getattr(type(call_target), _WRAPPED_ATTR, False):
747
+ return server
748
+ self._install(call_target, add_target, is_class=False)
749
+ for tool in _registered_tools(add_target):
750
+ self._inject_fields(tool)
751
+ except Exception:
752
+ logger.debug("Failed to wrap MCP server", exc_info=True)
753
+ warnings.warn(
754
+ f"[raindrop-mcp] wrap() could not instrument {server!r}; its tool calls will not be recorded",
755
+ stacklevel=2,
756
+ )
757
+ return server
758
+
759
+ def _install(self, call_target: Any, add_target: Any, *, is_class: bool) -> None:
760
+ """Hooks ``add_tool`` on ``add_target`` and ``call_tool`` on ``call_target`` (usually the same ToolManager)."""
761
+ original_add = add_target.add_tool
762
+ original_call = call_target.call_tool
763
+ standalone = _is_standalone_fastmcp(call_target)
764
+ reenters = standalone and "run_middleware" in inspect.signature(original_call).parameters
765
+ wrapper = self
766
+
767
+ if is_class:
768
+
769
+ def add_tool(manager: Any, *args: Any, **kwargs: Any) -> Any:
770
+ return wrapper._on_add(lambda: original_add(manager, *args, **kwargs))
771
+
772
+ async def call_tool(manager: Any, *args: Any, **kwargs: Any) -> Any:
773
+ name, arguments, rest, kwargs = _call_args(args, kwargs)
774
+ return await wrapper._on_call(
775
+ manager,
776
+ name,
777
+ arguments,
778
+ _context_arg(rest, kwargs),
779
+ lambda a: original_call(manager, name, a, *rest, **kwargs),
780
+ standalone=standalone,
781
+ passthrough=reenters and kwargs.get("run_middleware") is False,
782
+ )
783
+
784
+ else:
785
+
786
+ def add_tool(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
787
+ return wrapper._on_add(lambda: original_add(*args, **kwargs))
788
+
789
+ async def call_tool(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
790
+ name, arguments, rest, kwargs = _call_args(args, kwargs)
791
+ return await wrapper._on_call(
792
+ call_target,
793
+ name,
794
+ arguments,
795
+ _context_arg(rest, kwargs),
796
+ lambda a: original_call(name, a, *rest, **kwargs),
797
+ standalone=standalone,
798
+ passthrough=reenters and kwargs.get("run_middleware") is False,
799
+ )
800
+
801
+ setattr(add_target, "add_tool", add_tool)
802
+ setattr(call_target, "call_tool", call_tool)
803
+ setattr(call_target, _WRAPPED_ATTR, True)
804
+ setattr(add_target, _WRAPPED_ATTR, True)
805
+
806
+ def _on_add(self, register: Callable[[], Any]) -> Any:
807
+ tool = register()
808
+ try:
809
+ self._inject_fields(tool)
810
+ except Exception:
811
+ logger.debug("%s not added to tool", "/".join(self._wanted_fields()), exc_info=True)
812
+ return tool
813
+
814
+ def _wanted_fields(self) -> Tuple[str, ...]:
815
+ if not self._turn_tracking:
816
+ return ()
817
+ return (TURN_ID, INTENT) if self._intent else (TURN_ID,)
818
+
819
+ def _inject_fields(self, tool: Any) -> None:
820
+ wanted = self._wanted_fields()
821
+ if not wanted or tool is None:
822
+ return
823
+ parameters = getattr(tool, "parameters", None)
824
+ if not isinstance(parameters, Mapping):
825
+ return
826
+ properties = parameters.get("properties")
827
+ properties = dict(properties) if isinstance(properties, Mapping) else {}
828
+ name = getattr(tool, "name", None)
829
+ added: List[str] = []
830
+ for field in wanted:
831
+ if field in properties:
832
+ # The tool's own parameter, not ours: leave it alone and hand it through untouched.
833
+ if isinstance(name, str):
834
+ self._owned_fields.setdefault(name, set()).add(field)
835
+ warnings.warn(_collision_warning(name, field), stacklevel=4)
836
+ continue
837
+ properties[field] = {"type": "string", "description": _DESCRIPTIONS[field]}
838
+ added.append(field)
839
+ tool.parameters = {**parameters, "properties": properties}
840
+ self._injected_tools[id(tool)] = tuple(added)
841
+
842
+ def _fields_of(self, manager: Any, name: str) -> Tuple[str, ...]:
843
+ """The fields this wrapper advertises on ``name`` (so a tool-owned one is never stripped)."""
844
+ wanted = self._wanted_fields()
845
+ if not wanted:
846
+ return ()
847
+ try:
848
+ tools = getattr(manager, "_tools", None)
849
+ tool = tools.get(name) if isinstance(tools, Mapping) else None
850
+ if tool is None:
851
+ owned = self._owned_fields.get(name, set())
852
+ return tuple(field for field in wanted if field not in owned)
853
+ return self._injected_tools.get(id(tool), ())
854
+ except Exception:
855
+ return wanted
856
+
857
+ # -- per call ----------------------------------------------------------- #
858
+
859
+ async def _on_call(
860
+ self,
861
+ manager: Any,
862
+ name: str,
863
+ arguments: Any,
864
+ ctx: Any,
865
+ invoke: Callable[[Any], Awaitable[Any]],
866
+ *,
867
+ standalone: bool = False,
868
+ passthrough: bool = False,
869
+ ) -> Any:
870
+ if passthrough:
871
+ holder = _inner_ctx.get()
872
+ if holder is not None:
873
+ # Re-entry from the middleware chain (however many times it retries): the outer call owns it.
874
+ if not holder:
875
+ holder.append(_standalone_context())
876
+ return await invoke(arguments)
877
+ # call_tool(run_middleware=False) straight from server code: no outer call owns it.
878
+ if standalone and ctx is None:
879
+ ctx = _standalone_context() # 2.x: the Context is already open around the manager call
880
+ lazy_ctx = standalone and ctx is None
881
+ try:
882
+ fields = self._fields_of(manager, name)
883
+ turn = _split_turn(arguments, fields) if fields else _new_turn(arguments)
884
+ if self._turn_tracking and not lazy_ctx:
885
+ turn = _with_meta_turn(ctx, turn)
886
+ # Echo only when the id we advertise is ours; a tool-owned ``_turn_id`` must not be fed one.
887
+ with_echo = TURN_ID in fields and turn.source == "echo"
888
+ started = time.time()
889
+ except Exception:
890
+ logger.debug("Failed to prepare %s; running uninstrumented", name, exc_info=True)
891
+ return await invoke(arguments)
892
+
893
+ holder: List[Any] = []
894
+ token = _inner_ctx.set(holder) if lazy_ctx else None
895
+ try:
896
+ result = await invoke(turn.args)
897
+ except Exception as error:
898
+ if token is not None:
899
+ _inner_ctx.reset(token)
900
+ if lazy_ctx and holder:
901
+ ctx = holder[0]
902
+ if self._turn_tracking:
903
+ turn = _with_meta_turn(ctx, turn)
904
+ with_echo = TURN_ID in fields and turn.source == "echo"
905
+ await self._record(name, turn, ctx, started, error=error)
906
+ # The SDK would turn this into an error result without the echo, and
907
+ # the model's next call would open a new turn; build the same result ourselves.
908
+ if not with_echo or isinstance(error, _protocol_errors()):
909
+ raise
910
+ try:
911
+ return _error_result(str(error) or type(error).__name__, turn.id, standalone=standalone)
912
+ except Exception:
913
+ raise error
914
+
915
+ if token is not None:
916
+ _inner_ctx.reset(token)
917
+ if lazy_ctx and holder:
918
+ ctx = holder[0]
919
+ if self._turn_tracking:
920
+ turn = _with_meta_turn(ctx, turn)
921
+ with_echo = TURN_ID in fields and turn.source == "echo"
922
+ await self._record(name, turn, ctx, started, result=result)
923
+ if not with_echo:
924
+ return result
925
+ try:
926
+ return _echo_turn_id(result, turn.id)
927
+ except Exception:
928
+ logger.debug("Failed to echo %s on %s", TURN_ID, name, exc_info=True)
929
+ return result
930
+
931
+ async def _record(
932
+ self,
933
+ name: str,
934
+ turn: _Turn,
935
+ ctx: Any,
936
+ started: float,
937
+ *,
938
+ result: Any = None,
939
+ error: Optional[BaseException] = None,
940
+ ) -> None:
941
+ if self._rd is None:
942
+ return
943
+ try:
944
+ self._ship(name, turn, ctx, started, result, error)
945
+ except Exception:
946
+ logger.debug("%s not shipped", name, exc_info=True)
947
+ return
948
+ if not self._await_delivery:
949
+ return
950
+ try:
951
+ import anyio
952
+
953
+ # Abandon (not join) the flush thread at the deadline, or the result stays blocked for a slow ingest.
954
+ abandon = "abandon_on_cancel" if "abandon_on_cancel" in inspect.signature(anyio.to_thread.run_sync).parameters else "cancellable"
955
+ with anyio.move_on_after(_SHIP_DEADLINE_S):
956
+ await anyio.to_thread.run_sync(self._rd.flush, **{abandon: True})
957
+ except Exception:
958
+ logger.debug("Delivery wait for %s failed", name, exc_info=True)
959
+
960
+ def _ship(
961
+ self,
962
+ name: str,
963
+ turn: _Turn,
964
+ ctx: Any,
965
+ started: float,
966
+ result: Any,
967
+ error: Optional[BaseException],
968
+ ) -> None:
969
+ assert self._rd is not None
970
+ # Free text the model wrote; ``redact_arguments=["_intent"]`` keeps it out of telemetry entirely.
971
+ intent = None if INTENT in self._redact_arguments else turn.intent
972
+ call = McpCallContext(
973
+ tool_name=name,
974
+ args=turn.args,
975
+ session_id=_session_id_of(ctx),
976
+ context=ctx,
977
+ client=_client_info_of(ctx),
978
+ intent=intent,
979
+ )
980
+ user_id = self._resolve_user_id(call)
981
+ if not user_id:
982
+ return
983
+ # Hashed with the caller so an echoed id from another caller can never land on their event.
984
+ digest = hashlib.sha256(f"{user_id}\n{turn.id}".encode()).digest()
985
+ event_id = digest[:16].hex()
986
+ convo_id = self._resolve_convo_id(call, user_id, started)
987
+ is_new = self._is_new_meta_event(event_id) if turn.source == "meta" else turn.is_new
988
+ is_error = error is not None or _is_error_result(result)
989
+ output = self._output_of(name, result, error)
990
+ ended = time.time()
991
+
992
+ properties: Dict[str, Any] = dict(self._resolve_properties(call))
993
+ properties.update({"mcp.turn_id": turn.id, "mcp.tool": name, "mcp.is_error": is_error})
994
+ span_properties: Dict[str, Any] = {"mcp.turn_id": turn.id}
995
+ if call.session_id:
996
+ properties["mcp.session_id"] = call.session_id
997
+ span_properties["mcp.session_id"] = call.session_id
998
+ if call.client is not None:
999
+ properties["mcp.client_name"] = span_properties["mcp.client_name"] = call.client.name
1000
+ if call.client.version is not None:
1001
+ properties["mcp.client_version"] = span_properties["mcp.client_version"] = call.client.version
1002
+ if intent is not None:
1003
+ properties["mcp.intent"] = span_properties["mcp.intent"] = intent
1004
+
1005
+ token = _attach_turn_trace(digest)
1006
+ try:
1007
+ interaction = self._rd.begin(
1008
+ user_id,
1009
+ self._event_name,
1010
+ event_id=event_id,
1011
+ convo_id=convo_id,
1012
+ input=_session_input(intent) if is_new or intent is not None else None,
1013
+ )
1014
+ interaction.track_tool(
1015
+ name=name,
1016
+ input=self._args_of(turn.args),
1017
+ output=output,
1018
+ error=(output or "Tool reported an error") if is_error else None,
1019
+ duration_ms=max(0.0, (ended - started) * 1000),
1020
+ start_time=started,
1021
+ properties=span_properties,
1022
+ )
1023
+ finish_kwargs: Dict[str, Any] = {"properties": properties}
1024
+ if is_new:
1025
+ finish_kwargs["timestamp"] = datetime.fromtimestamp(started, tz=timezone.utc)
1026
+ interaction.finish(output="MCP session ended", **finish_kwargs)
1027
+ finally:
1028
+ _detach_turn_trace(token)
1029
+
1030
+ def _is_new_meta_event(self, event_id: str) -> bool:
1031
+ if event_id in self._opened_meta_events:
1032
+ return False
1033
+ if len(self._opened_meta_events) >= _MAX_META_EVENTS:
1034
+ self._opened_meta_events.clear()
1035
+ self._opened_meta_events.add(event_id)
1036
+ return True
1037
+
1038
+ def _resolve_user_id(self, call: McpCallContext) -> Optional[str]:
1039
+ if self._user_id is None:
1040
+ return _default_user_id(call.context)
1041
+ if isinstance(self._user_id, str):
1042
+ return self._user_id
1043
+ value = self._user_id(call)
1044
+ return value if isinstance(value, str) and value else None
1045
+
1046
+ def _resolve_convo_id(self, call: McpCallContext, user_id: str, started: float) -> Optional[str]:
1047
+ """Clients choose the session id, so it is hashed with the caller; without one, a caller's calls group by UTC day."""
1048
+ if isinstance(self._convo_id, str):
1049
+ return self._convo_id
1050
+ if self._convo_id is not None:
1051
+ value = self._convo_id(call)
1052
+ return value if isinstance(value, str) and value else None
1053
+ key = call.session_id or datetime.fromtimestamp(started, tz=timezone.utc).strftime("%Y-%m-%d")
1054
+ return hashlib.sha256(f"{user_id}\n{key}".encode()).hexdigest()[:32]
1055
+
1056
+ def _resolve_properties(self, call: McpCallContext) -> Dict[str, Any]:
1057
+ try:
1058
+ if self._properties is None:
1059
+ return {}
1060
+ if isinstance(self._properties, Mapping):
1061
+ return dict(self._properties)
1062
+ value = self._properties(call)
1063
+ return dict(value) if isinstance(value, Mapping) else {}
1064
+ except Exception:
1065
+ logger.debug("properties callable failed", exc_info=True)
1066
+ return {}
1067
+
1068
+ def _args_of(self, args: Dict[str, Any]) -> Dict[str, Any]:
1069
+ kept = {key: value for key, value in args.items() if key not in self._redact_arguments}
1070
+ redacted = _redact_secrets(kept, [10_000], self._max_text_field_chars)
1071
+ return redacted if isinstance(redacted, dict) else {}
1072
+
1073
+ def _output_of(self, name: str, result: Any, error: Optional[BaseException]) -> str:
1074
+ if name in self._redact_results:
1075
+ return "[redacted]"
1076
+ if error is not None:
1077
+ return _error_message(error)[: self._max_text_field_chars]
1078
+ return _tool_result_text(result, self._max_text_field_chars)
1079
+
1080
+ # -- pass-through API ---------------------------------------------------- #
1081
+
1082
+ def flush(self) -> None:
1083
+ try:
1084
+ if self._rd is not None:
1085
+ self._rd.flush()
1086
+ except Exception:
1087
+ logger.debug("Failed to flush", exc_info=True)
1088
+
1089
+ def shutdown(self) -> None:
1090
+ try:
1091
+ if self._rd is not None:
1092
+ self._rd.shutdown()
1093
+ except Exception:
1094
+ logger.debug("Failed to shutdown", exc_info=True)
1095
+
1096
+ def identify(self, user_id: str, traits: Optional[Dict[str, Union[str, int, bool, float]]] = None) -> None:
1097
+ try:
1098
+ if self._rd is not None:
1099
+ self._rd.identify(user_id, traits)
1100
+ except Exception:
1101
+ logger.debug("Failed to identify", exc_info=True)
1102
+
1103
+ def track_signal(
1104
+ self,
1105
+ event_id: str,
1106
+ name: str,
1107
+ signal_type: Literal["default", "feedback", "edit"] = "default",
1108
+ *,
1109
+ timestamp: Optional[str] = None,
1110
+ properties: Optional[Dict[str, object]] = None,
1111
+ attachment_id: Optional[str] = None,
1112
+ comment: Optional[str] = None,
1113
+ after: Optional[str] = None,
1114
+ sentiment: Optional[Literal["POSITIVE", "NEGATIVE"]] = None,
1115
+ ) -> None:
1116
+ try:
1117
+ if self._rd is not None:
1118
+ self._rd.track_signal(
1119
+ event_id=event_id,
1120
+ name=name,
1121
+ signal_type=signal_type,
1122
+ timestamp=timestamp,
1123
+ properties=properties,
1124
+ attachment_id=attachment_id,
1125
+ comment=comment,
1126
+ after=after,
1127
+ sentiment=sentiment,
1128
+ )
1129
+ except Exception:
1130
+ logger.debug("Failed to track signal", exc_info=True)
1131
+
1132
+ def __getitem__(self, key: str) -> Any:
1133
+ methods = {
1134
+ "wrap": self.wrap,
1135
+ "flush": self.flush,
1136
+ "shutdown": self.shutdown,
1137
+ "identify": self.identify,
1138
+ "track_signal": self.track_signal,
1139
+ }
1140
+ if key not in methods:
1141
+ raise KeyError(key)
1142
+ return methods[key]
1143
+
1144
+
1145
+ def _call_args(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Tuple[Any, Any, Tuple[Any, ...], Dict[str, Any]]:
1146
+ """``(name, arguments, remaining args, kwargs)`` however the SDK spelled the call (``key=`` in standalone fastmcp 2.x)."""
1147
+ rest = list(args)
1148
+ kwargs = dict(kwargs)
1149
+ if rest:
1150
+ name = rest.pop(0)
1151
+ else:
1152
+ name = kwargs.pop("name") if "name" in kwargs else kwargs.pop("key")
1153
+ arguments = rest.pop(0) if rest else kwargs.pop("arguments", None)
1154
+ return name, arguments, tuple(rest), kwargs
1155
+
1156
+
1157
+ def _context_arg(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any:
1158
+ if "context" in kwargs:
1159
+ return kwargs["context"]
1160
+ return args[0] if args else None
1161
+
1162
+
1163
+ def create_raindrop_mcp(**kwargs: Any) -> RaindropMcp:
1164
+ """Factory form of :class:`RaindropMcp`; returns a no-op instance instead of raising."""
1165
+ try:
1166
+ return RaindropMcp(**kwargs)
1167
+ except Exception:
1168
+ logger.debug("Failed to construct RaindropMcp", exc_info=True)
1169
+ warnings.warn("[raindrop-mcp] telemetry disabled; see debug logs", stacklevel=2)
1170
+ instance = object.__new__(RaindropMcp)
1171
+ instance._rd = None
1172
+ instance._turn_tracking = bool(kwargs.get("turn_tracking", True))
1173
+ instance._intent = bool(kwargs.get("intent", True))
1174
+ instance._user_id = kwargs.get("user_id")
1175
+ instance._convo_id = kwargs.get("convo_id")
1176
+ instance._properties = kwargs.get("properties")
1177
+ instance._event_name = kwargs.get("event_name") or "mcp_activity"
1178
+ instance._redact_arguments = set(kwargs.get("redact_arguments") or ())
1179
+ instance._redact_results = set(kwargs.get("redact_results") or ())
1180
+ instance._await_delivery = bool(kwargs.get("await_delivery", True))
1181
+ instance._max_text_field_chars = _DEFAULT_MAX_TEXT_FIELD_CHARS
1182
+ instance._opened_meta_events = set()
1183
+ instance._injected_tools = {}
1184
+ instance._owned_fields = {}
1185
+ return instance
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: raindrop-mcp
3
+ Version: 0.0.1
4
+ Summary: Raindrop instrumentation for Python MCP servers: one event per turn, one tool span per call, for any FastMCP / MCPServer.
5
+ Project-URL: Homepage, https://raindrop.ai
6
+ Project-URL: Repository, https://github.com/invisible-tools/dawn
7
+ Author-email: Raindrop AI <sdk@raindrop.ai>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: mcp>=1.10.0
13
+ Requires-Dist: raindrop-ai>=0.0.70
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # raindrop-mcp
20
+
21
+ Raindrop instrumentation for Python MCP servers. Works with the official SDK's `mcp` 1.x (`FastMCP`) and `mcp` 2.x (`MCPServer`), with the standalone [`fastmcp`](https://pypi.org/project/fastmcp/) package (2.x and 3.x+), and with any server built on them: wrap it once and every tool call lands in Raindrop as a tool span on an `mcp_activity` event. Python twin of [`@raindrop-ai/mcp`](https://github.com/raindrop-ai/raindrop-js/tree/main/packages/mcp).
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install raindrop-mcp
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```python
32
+ import os
33
+ from mcp.server.fastmcp import FastMCP # mcp 2.x: from mcp.server.mcpserver import MCPServer
34
+ from raindrop_mcp import RaindropMcp
35
+
36
+ raindrop = RaindropMcp(api_key=os.environ.get("RAINDROP_API_KEY"))
37
+ mcp = raindrop.wrap(FastMCP("my-server"))
38
+
39
+ @mcp.tool()
40
+ def search(query: str) -> str:
41
+ ...
42
+
43
+ mcp.run()
44
+ ```
45
+
46
+ Omitting `api_key` disables shipping (a warning is emitted) but never crashes the server.
47
+
48
+ Third-party servers usually build their own `FastMCP` at import time. Wrap the class before importing them, or wrap the instance afterwards (tools already registered are instrumented too):
49
+
50
+ ```python
51
+ from mcp.server.fastmcp import FastMCP
52
+ from raindrop_mcp import RaindropMcp
53
+
54
+ RaindropMcp(api_key=...).wrap(FastMCP) # every FastMCP built from now on
55
+ import some_mcp_server # noqa: E402
56
+ some_mcp_server.mcp.run()
57
+ ```
58
+
59
+ The standalone `fastmcp` package works the same way:
60
+
61
+ ```python
62
+ from fastmcp import FastMCP
63
+ from raindrop_mcp import RaindropMcp
64
+
65
+ mcp = RaindropMcp(api_key=...).wrap(FastMCP("Demo"))
66
+
67
+ @mcp.tool
68
+ def add(a: int, b: int) -> int:
69
+ return a + b
70
+ ```
71
+
72
+ ## Turn tracking
73
+
74
+ MCP has no turn boundary, so by default every tool advertises an optional `_turn_id` argument and every result ends with a `_turn_id: <id>` text block. The model copies it on its next call and omits it on a new prompt, so the calls of one prompt share one event (one trace, one tool span per call). The argument is removed before your tool runs. A tool that raises still echoes the id, so the model's next call stays on the same event.
75
+
76
+ If the client sends `_meta["io.modelcontextprotocol/aiInvocation"].turnId` (SEP-2817), that id is used and nothing is echoed.
77
+
78
+ `turn_tracking=False` leaves schemas and results untouched (this also disables `intent`); every call is then its own event.
79
+
80
+ ## Intent and client
81
+
82
+ Every tool also advertises an optional `_intent` argument: one sentence on what the user is trying to accomplish, which the model fills on the first call of a prompt. It is stripped before your tool runs, recorded as the `mcp.intent` property, and appended to the event input under a `Session intent:` line after `MCP session started`. Set `intent=False` to leave it out. It is free text the model wrote, so the key-based secret redaction does not apply to it; list `_intent` in `redact_arguments` to keep the field advertised but out of telemetry.
83
+
84
+ The MCP client that made the call (`clientInfo`, e.g. `claude-code 2.1.0`, `Cursor`, `codex-mcp-client`) is recorded as `mcp.client_name` / `mcp.client_version` on every span and event. It comes from the per-request `_meta` of 2026-era clients, or from the `initialize` handshake on older ones.
85
+
86
+ ## Options
87
+
88
+ | Option | Default | Description |
89
+ | ---------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
90
+ | `api_key` | – | Raindrop write key. Without one, nothing ships and a warning is emitted. |
91
+ | `endpoint` | `https://api.raindrop.ai/v1/` | Ingest endpoint. |
92
+ | `project_id` | – | Raindrop project slug. |
93
+ | `event_name` | `mcp_activity` | Event name. |
94
+ | `turn_tracking` | `True` | Advertise and echo `_turn_id` to group a prompt's calls into one event. |
95
+ | `intent` | `True` | Advertise `_intent` so the model states the user's goal on the first call of a prompt. |
96
+ | `user_id` | OAuth subject → hashed bearer token → `session:<id>` → `anonymous` | Fixed id or `callable(call) -> str \| None`. Returning `None` skips the call. |
97
+ | `convo_id` | transport session, else the caller's UTC day | Fixed id or `callable(call) -> str \| None`. |
98
+ | `properties` | – | Extra event properties, a dict or `callable(call) -> dict`. |
99
+ | `redact_arguments` | `[]` | Argument names never captured. Keys that look like secrets (`password`, `authorization`, `api_key`, …) are always redacted. |
100
+ | `redact_results` | `[]` | Tools whose results are never captured, e.g. ones that return credentials. |
101
+ | `await_delivery` | `True` | Wait (at most 5s, off the event loop) for delivery before returning each tool result. Needed on serverless. Set `False` on long-running servers to keep tool latency unchanged. |
102
+ | `max_text_field_chars` | `1,000,000` | Per-field character cap for captured arguments and results. |
103
+ | `debug` | `False` | Log shipping details. |
104
+ | `app_git` | auto-detect | Application Git identity; `False` disables. |
105
+ | `client` | – | An existing `raindrop.Raindrop` client to ship through (multi-project setups). |
106
+
107
+ `call` passed to the callables is a `McpCallContext(tool_name, args, session_id, context)`, where `context` is the SDK's per-request `Context`.
108
+
109
+ ## Event properties
110
+
111
+ `mcp.turn_id`, `mcp.tool` (the latest tool in the turn), `mcp.is_error`, `mcp.client_name` / `mcp.client_version` when the client identified itself, `mcp.intent` when the model filled `_intent`, `mcp.session_id` when the transport has one, plus anything from `properties`. Tool spans carry the arguments, the result text (or the error), `mcp.turn_id`, `mcp.session_id`, the client and the intent.
112
+
113
+ ## Known limitations
114
+
115
+ - Servers built on the official SDK's `FastMCP` / `MCPServer` or on standalone `fastmcp` are supported; the low-level `mcp.server.Server` is not.
116
+ - On standalone `fastmcp` 2.x a tool that raises cannot echo the id (its `ToolResult` has no error form), so the model's next call starts a new event; 3.x+ echoes like the official SDK.
117
+ - `_turn_id` and `_intent` are added to the tool's JSON schema. A tool that declares either name itself keeps it: the field is neither advertised by this package nor stripped from the arguments for that tool, and a warning is emitted at registration since that tool then has no intent (or no turn grouping).
118
+ - Grouping depends on the model echoing `_turn_id`; clients that strip unknown result blocks start a new event per call.
119
+ - Delivery runs on the Raindrop SDK's background thread; `await_delivery` flushes before returning but cannot wait for a flush that thread already started.
120
+
121
+ ## Testing
122
+
123
+ ```bash
124
+ pip install -e ".[dev]" && pytest
125
+ ```
126
+
127
+ The suite runs unchanged against `mcp<2` and `mcp>=2`.
@@ -0,0 +1,7 @@
1
+ raindrop_mcp/__init__.py,sha256=hnjKmlU9hwP_N-grV6Bvix_d0jjo2pvNAeLID9158Io,286
2
+ raindrop_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ raindrop_mcp/wrapper.py,sha256=ZevzmSgBM9Pwy1TJU4DohAz8CSyHHxHqtJOb1G72nL8,47983
4
+ raindrop_mcp-0.0.1.dist-info/METADATA,sha256=fZuLJ1zCSZGbGSla7RnyQrC-yb9wHO6F3E7v5vbWDuk,9857
5
+ raindrop_mcp-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ raindrop_mcp-0.0.1.dist-info/licenses/LICENSE,sha256=75M-fO9wwGEb_cxFixHCC7ubCg-Y0ryPwIySFUuMr-o,1068
7
+ raindrop_mcp-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raindrop AI
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.