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.
raindrop_mcp/__init__.py
ADDED
|
@@ -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
|
raindrop_mcp/wrapper.py
ADDED
|
@@ -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,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.
|