Skip to content

Commit c6d9e07

Browse files
authored
feat(mcp): record safe tool input names
Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe
1 parent 24c91d0 commit c6d9e07

14 files changed

Lines changed: 360 additions & 8 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
Record safe tool input field names on MCP tool-call events. Add server-owned input alias maps for automatic instrumentation and a public helper for custom dispatchers. The SDK records field names and alias use without reading argument values or changing tool calls.

‎posthog/mcp/README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ The value must contain 1 to 256 characters. The SDK records it as
6767
`$mcp_server_build` on all MCP events. `PostHogMCP.capture()` also adds it to
6868
custom events unless the event provides its own value.
6969

70+
## Capture safe input field names
71+
72+
Tool-call events include `$mcp_input_keys`. The SDK records names from the
73+
server's input schema. It replaces unknown names with one `[redacted]` entry.
74+
Argument values do not affect this property.
75+
76+
Use `resolve_input_aliases` when a tool accepts alternative names. The map uses
77+
each canonical name as a key. Its value lists accepted aliases in server order.
78+
79+
```python
80+
instrument(
81+
server,
82+
posthog,
83+
MCPAnalyticsOptions(
84+
resolve_input_aliases=lambda tool_name: (
85+
{"location": ["city", "place"]}
86+
if tool_name == "weather-current"
87+
else None
88+
)
89+
),
90+
)
91+
```
92+
93+
The SDK records `city` in `$mcp_input_keys`. It also records
94+
`city:location` in `$mcp_input_aliases_used`. The SDK does not change the call.
95+
96+
Custom dispatchers can call `get_tool_input_properties()` and add its result to
97+
the `properties` argument of `capture_tool_call()`.
98+
7099
Model capture adds an `llm_model` argument to compatible tool schemas, required on the official
71100
high-level adapters and optional elsewhere. Dispatch never enforces it, so servers keep working;
72101
strict-schema clients see the new field. Set `capture_model=False` to leave schemas untouched.

‎posthog/mcp/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
)
4747
from ._event_types import MCPAnalyticsEventType
4848
from ._instrumentation import drain_pending
49+
from ._tool_input import get_tool_input_properties
4950
from ._internal import (
5051
MCPAnalyticsData,
5152
get_server_tracking_data,
@@ -82,12 +83,15 @@
8283
CaptureEventData,
8384
CollectFeedbackOptions,
8485
FeedbackReport,
86+
InputAliasMap,
8587
MCPAnalyticsContextOptions,
8688
MCPAnalyticsModelOptions,
8789
MCPAnalyticsModelSource,
8890
MCPAnalyticsOptions,
8991
PreparedToolCall,
9092
PreparedToolResult,
93+
ShouldRecordInputKeyFn,
94+
ToolInputOptions,
9195
UserIdentity,
9296
)
9397
from .version import __version__
@@ -104,8 +108,12 @@
104108
"CaptureEventData",
105109
"CollectFeedbackOptions",
106110
"FeedbackReport",
111+
"InputAliasMap",
107112
"PreparedToolCall",
108113
"PreparedToolResult",
114+
"ShouldRecordInputKeyFn",
115+
"ToolInputOptions",
116+
"get_tool_input_properties",
109117
"get_more_tools_result",
110118
"send_feedback_result",
111119
"SEND_FEEDBACK_TOOL_NAME",

‎posthog/mcp/_instrument_fastmcp.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ async def list_handler(req: Any) -> Any:
272272
duration_ms = (time.monotonic() - start) * 1000
273273
tools = extract_tools(result)
274274
# Empty is computed before adding the virtual missing-capability tool.
275-
names, empty = collect_listed_tools(data, tools)
275+
names, empty = collect_listed_tools(data, tools, lifecycle.session_id)
276276
injection = resolve_virtual_tool_injection(
277277
data,
278278
tools,

‎posthog/mcp/_instrument_lowlevel.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ async def handler(req: Any) -> Any:
472472

473473
# Zero advertised tools is treated as an errored tools/list before the
474474
# virtual missing-capability tool is appended.
475-
names, empty = collect_listed_tools(data, tools)
475+
names, empty = collect_listed_tools(data, tools, lifecycle.session_id)
476476
injection = resolve_virtual_tool_injection(
477477
data,
478478
tools,

‎posthog/mcp/_instrument_v2.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ async def handler(ctx: Any, params: Any) -> Any:
695695

696696
tools = list(getattr(result, "tools", []) or [])
697697
# Empty is computed before adding the virtual missing-capability tool.
698-
names, empty = collect_listed_tools(data, tools)
698+
names, empty = collect_listed_tools(data, tools, lifecycle.session_id)
699699
injection = resolve_virtual_tool_injection(
700700
data, tools, is_first_page=is_first_listing_page(params)
701701
)

‎posthog/mcp/_instrumentation.py‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
)
4242
from ._intent import resolve_tool_call_intent, set_event_intent
4343
from ._internal import MCPAnalyticsData, handle_identify, resolve_event_properties
44+
from ._tool_input import get_tool_input_properties
4445
from ._model_parameters import (
4546
add_model_parameter_to_schema,
4647
get_model_description,
@@ -55,7 +56,7 @@
5556
from .session import resolve_session_id, resolve_session_id_with_source
5657
from .session_token import SessionTokenPayload, decode_session_id
5758
from .tools import resolve_missing_capability_tool_name
58-
from .types import CollectFeedbackOptions, FeedbackReport
59+
from .types import CollectFeedbackOptions, FeedbackReport, ToolInputOptions
5960

6061
# The virtual tools this SDK advertises into tools/list. Every piece of per-tool
6162
# policy -- enable switch, configured name, warning text, warn-once
@@ -677,8 +678,24 @@ async def record_tool_call(
677678
event["error"] = capture_exception(result)
678679

679680
props = await resolve_event_properties(data, request, extra)
680-
if props is not None:
681-
event["properties"] = props
681+
input_aliases = None
682+
if data.options.resolve_input_aliases is not None:
683+
try:
684+
input_aliases = data.options.resolve_input_aliases(name)
685+
except Exception as err: # noqa: BLE001 - analytics callbacks are isolated
686+
log(f"Warning: resolve_input_aliases failed for tool {name}: {err}")
687+
schema = data.tool_input_schemas.get(session_id, {}).get(name)
688+
event["properties"] = {
689+
**(props or {}),
690+
**get_tool_input_properties(
691+
arguments or {},
692+
schema,
693+
ToolInputOptions(
694+
should_record_input_key=data.options.should_record_input_key,
695+
input_aliases=input_aliases,
696+
),
697+
),
698+
}
682699

683700
stamp_transport_identity(event, extra)
684701
fire_and_forget(capture_event(data, event), data)
@@ -991,16 +1008,29 @@ def read_tool_category(tool: Any) -> Optional[str]:
9911008
return None
9921009

9931010

994-
def collect_listed_tools(data: MCPAnalyticsData, tools: list) -> tuple[List[str], bool]:
1011+
def collect_listed_tools(
1012+
data: MCPAnalyticsData, tools: list, session_id: Optional[str] = None
1013+
) -> tuple[List[str], bool]:
9951014
"""Cache common tool metadata and return the pre-injection listing summary."""
9961015
names = []
1016+
schemas = data.tool_input_schemas.get(session_id, {}) if session_id else None
9971017
for tool in tools:
9981018
names.append(tool.name)
9991019
if getattr(tool, "description", None):
10001020
data.tool_descriptions[tool.name] = tool.description
10011021
category = read_tool_category(tool)
10021022
if category:
10031023
data.tool_categories[tool.name] = category
1024+
if schemas is not None:
1025+
schema = getattr(tool, "inputSchema", None)
1026+
if schema is None:
1027+
schema = getattr(tool, "input_schema", None)
1028+
schemas[tool.name] = schema
1029+
if session_id and schemas is not None:
1030+
data.tool_input_schemas[session_id] = schemas
1031+
data.tool_input_schemas.move_to_end(session_id)
1032+
while len(data.tool_input_schemas) > 1000:
1033+
data.tool_input_schemas.popitem(last=False)
10041034
return names, not tools
10051035

10061036

‎posthog/mcp/_internal.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ class MCPAnalyticsData:
8888
identified_sessions: IdentityCache = field(default_factory=IdentityCache)
8989
tool_categories: Dict[str, str] = field(default_factory=dict)
9090
tool_descriptions: Dict[str, str] = field(default_factory=dict)
91+
# Original tool input schemas by PostHog session. This keeps field-name
92+
# privacy decisions isolated when a server advertises user-specific tools.
93+
tool_input_schemas: "OrderedDict[str, Dict[str, Any]]" = field(
94+
default_factory=OrderedDict
95+
)
9196
# True only when PostHog added llm_model to this tool's advertised schema.
9297
# Missing/False fails closed so an application-owned field is never read or stripped.
9398
tool_model_parameter_injected: Dict[str, bool] = field(default_factory=dict)

‎posthog/mcp/_tool_input.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Describe tool arguments by field name without reading their values."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Dict, List, Optional
6+
7+
from .constants import PostHogMCPAnalyticsProperty
8+
from .types import InputAliasMap, JsonRecord, ToolInputOptions
9+
10+
_MAX_INPUT_KEYS = 20
11+
_MAX_KEY_LENGTH = 64
12+
_ANALYTICS_KEYS = {"context", "llm_model", "conversation_id"}
13+
14+
15+
def _declared_properties(schema: Any) -> Dict[str, Any]:
16+
if not isinstance(schema, dict):
17+
return {}
18+
properties = schema.get("properties")
19+
return properties if isinstance(properties, dict) else {}
20+
21+
22+
def _alias_names(aliases: Optional[InputAliasMap]) -> List[str]:
23+
if not isinstance(aliases, dict):
24+
return []
25+
return [
26+
name
27+
for names in aliases.values()
28+
if isinstance(names, (list, tuple))
29+
for name in names
30+
if isinstance(name, str)
31+
]
32+
33+
34+
def _aliases_used(
35+
aliases: Optional[InputAliasMap], input_value: Dict[str, Any]
36+
) -> List[str]:
37+
if not isinstance(aliases, dict):
38+
return []
39+
used = []
40+
for canonical, names in aliases.items():
41+
if not isinstance(canonical, str) or canonical in input_value:
42+
continue
43+
names_value: Any = names
44+
if not isinstance(names_value, (list, tuple)):
45+
continue
46+
alias = next(
47+
(
48+
name
49+
for name in names_value
50+
if isinstance(name, str) and name in input_value
51+
),
52+
None,
53+
)
54+
if (
55+
alias
56+
and len(alias) <= _MAX_KEY_LENGTH
57+
and len(canonical) <= _MAX_KEY_LENGTH
58+
):
59+
used.append(f"{alias}:{canonical}")
60+
return sorted(used)[:_MAX_INPUT_KEYS]
61+
62+
63+
def get_tool_input_properties(
64+
input_value: Any,
65+
input_schema: Any = None,
66+
options: Optional[ToolInputOptions] = None,
67+
) -> JsonRecord:
68+
"""Describe tool arguments without reading their values.
69+
70+
The schema and aliases must come from the server. Unknown names become one
71+
``[redacted]`` entry because an argument name can contain private data.
72+
"""
73+
try:
74+
if type(input_value) is not dict:
75+
return {}
76+
resolved = options or ToolInputOptions()
77+
properties = _declared_properties(input_schema)
78+
known = set(properties) | set(_alias_names(resolved.input_aliases))
79+
keys = [
80+
key
81+
for key in input_value
82+
if isinstance(key, str) and (key in known or key not in _ANALYTICS_KEYS)
83+
]
84+
declared: List[str] = []
85+
undeclared: List[str] = []
86+
has_redacted = False
87+
for key in keys:
88+
is_declared = key in known
89+
record = is_declared
90+
if resolved.should_record_input_key is not None:
91+
try:
92+
record = (
93+
resolved.should_record_input_key(key, {"declared": is_declared})
94+
is True
95+
)
96+
except Exception: # noqa: BLE001 - analytics callbacks fail closed
97+
record = False
98+
if len(key) <= _MAX_KEY_LENGTH and record:
99+
(declared if is_declared else undeclared).append(key)
100+
else:
101+
has_redacted = True
102+
103+
visible = [*sorted(declared), *sorted(undeclared)][:_MAX_INPUT_KEYS]
104+
if has_redacted and len(visible) < _MAX_INPUT_KEYS:
105+
visible.append("[redacted]")
106+
aliases_used = _aliases_used(resolved.input_aliases, input_value)
107+
result: JsonRecord = {
108+
PostHogMCPAnalyticsProperty.INPUT_KEYS: visible,
109+
}
110+
if aliases_used:
111+
result[PostHogMCPAnalyticsProperty.INPUT_ALIASES_USED] = aliases_used
112+
return result
113+
except Exception: # noqa: BLE001 - analytics must not change tool dispatch
114+
return {}

‎posthog/mcp/constants.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ class PostHogMCPAnalyticsProperty:
8787
IS_ERROR = "$mcp_is_error"
8888
INTENT = "$mcp_intent"
8989
INTENT_SOURCE = "$mcp_intent_source"
90+
INPUT_ALIASES_USED = "$mcp_input_aliases_used"
91+
INPUT_KEYS = "$mcp_input_keys"
9092
LLM_MODEL = "$mcp_llm_model"
9193
LLM_MODEL_SOURCE = "$mcp_llm_model_source"
9294
LISTED_TOOL_NAMES = "$mcp_listed_tool_names"

0 commit comments

Comments
 (0)