Skip to content

Commit 8ef0573

Browse files
authored
feat(mcp): record safe tool input names (#989)
* feat(mcp): record safe tool input names Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe * fix(mcp): scope input schemas to tool calls * fix(mcp): resolve referenced input schemas Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe * fix(mcp): address input name review Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe
1 parent 48bb7da commit 8ef0573

15 files changed

Lines changed: 624 additions & 35 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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,46 @@ 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+
The SDK records at most 20 names. Each name can contain at most 64 characters.
77+
Use `should_record_input_key` to control names in both input properties. The
78+
callback receives the name and `{"declared": bool}`. Return `True` only for
79+
names that the SDK can record.
80+
81+
Raw low-level servers have no trusted tool registry. The SDK redacts names when
82+
it cannot resolve the current schema for a call.
83+
84+
Use `resolve_input_aliases` when a tool accepts alternative names. The map uses
85+
each canonical name as a key. Its value lists accepted aliases in server order.
86+
87+
```python
88+
instrument(
89+
server,
90+
posthog,
91+
MCPAnalyticsOptions(
92+
resolve_input_aliases=lambda tool_name: (
93+
{"location": ["city", "place"]}
94+
if tool_name == "weather-current"
95+
else None
96+
)
97+
),
98+
)
99+
```
100+
101+
The SDK records `city` in `$mcp_input_keys`. It also records
102+
`city:location` in `$mcp_input_aliases_used`. The SDK does not change the call.
103+
104+
This safe-name rule does not change `$mcp_parameters`. That property still
105+
contains the sanitized tool arguments and their original names.
106+
107+
Custom dispatchers can call `get_tool_input_properties()` and add its result to
108+
the `properties` argument of `capture_tool_call()`.
109+
70110
Model capture adds an `llm_model` argument to compatible tool schemas, required on the official
71111
high-level adapters and optional elsewhere. Dispatch never enforces it, so servers keep working;
72112
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import inspect
2424
import time
25+
from dataclasses import replace
2526
from typing import Any, Dict, Optional, Tuple
2627

2728
import mcp.types as mcp_types
@@ -48,6 +49,7 @@
4849
request_meta_from_context,
4950
)
5051
from ._output_instructions import mirror_instructions_into_structured_content
52+
from ._tool_input import get_registered_tool_input_schema
5153
from .logger import log
5254
from .tools import get_more_tools_result_text
5355

@@ -131,6 +133,11 @@ async def wrapped(
131133
for text in lifecycle.virtual_result_texts(reply)
132134
]
133135

136+
lifecycle = replace(
137+
lifecycle,
138+
input_schema=get_registered_tool_input_schema(server, name),
139+
)
140+
134141
# Strip each injected key independently. A tool can declare its own
135142
# `context` (kept) while `conversation_id` is still SDK-injected (stripped),
136143
# so coupling both to context-ownership leaked conversation_id into the tool.

‎posthog/mcp/_instrument_lowlevel.py‎

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ def _wrap_call_tool(
186186
async def handler(req: Any) -> Any:
187187
name = req.params.name
188188
arguments = dict(req.params.arguments or {})
189-
strip, model_ours = (
189+
strip, model_ours, input_schema = (
190190
await _standalone_ownership(data, high_level, name, req.params.meta)
191191
if strip_injected
192-
else (set(), data.tool_model_parameter_injected.get(name))
192+
else (set(), data.tool_model_parameter_injected.get(name), None)
193193
)
194194
client_name, client_version = _client_info(server)
195195
protocol_version = _protocol_version(server)
@@ -212,6 +212,7 @@ async def handler(req: Any) -> Any:
212212
client_version=client_version,
213213
protocol_version=protocol_version,
214214
extra={"session_id": mcp_session_id, "ctx": _request_context(server)},
215+
input_schema=input_schema,
215216
)
216217

217218
if lifecycle.is_missing_capability and (
@@ -565,10 +566,11 @@ def _tool_lookup_not_found_errors() -> Tuple[type, ...]:
565566

566567
async def _standalone_ownership(
567568
data: MCPAnalyticsData, high_level: Any, name: str, meta: Any
568-
) -> Tuple[set, Optional[bool]]:
569+
) -> Tuple[set, Optional[bool], Optional[Dict[str, Any]]]:
569570
"""Ownership of the injected arguments on jlowin's standalone FastMCP: the
570571
keys to strip before it validates the call, and whether ``llm_model`` is
571-
ours (``None`` when nothing can say).
572+
ours (``None`` when nothing can say). The third item is the trusted schema
573+
for input-name analytics, or ``None`` when middleware can change dispatch.
572574
573575
Only keys injected under the current options are candidates. ``context``
574576
and ``conversation_id`` are stripped unless the registered schema (or,
@@ -580,17 +582,44 @@ async def _standalone_ownership(
580582
stays and is still read (posthog-js ADR-0011).
581583
"""
582584
try:
583-
declared, model_injectable = await _registry_view(high_level, name, meta)
585+
declared, model_injectable, input_schema = await _registry_view(
586+
high_level, name, meta
587+
)
584588
model_ours = data.tool_model_parameter_injected.get(name, model_injectable)
585589
if _dispatch_can_differ(high_level):
586590
model_ours = False
591+
input_schema = await _listed_input_schema(high_level, name, meta)
587592
except Exception: # noqa: BLE001 - ownership inference must never prevent dispatch
588-
declared, model_ours = None, None
593+
declared, model_ours, input_schema = None, None, None
589594
candidates = _injected_keys(data)
590595
strip = {k for k in candidates - {"llm_model"} if k not in (declared or set())}
591596
if "llm_model" in candidates and model_ours:
592597
strip.add("llm_model")
593-
return strip, model_ours
598+
return strip, model_ours, input_schema
599+
600+
601+
def _tool_schema_view(
602+
high_level: Any, tool: Any
603+
) -> Tuple[Optional[set], Optional[bool], Optional[Dict[str, Any]]]:
604+
if tool is None:
605+
return None, None, None
606+
schema = getattr(tool, "parameters", None)
607+
if isinstance(schema, dict):
608+
dereferenced = _server_dereferences(high_level)
609+
declared, injectable = _schema_view(schema, dereferenced=dereferenced)
610+
return (
611+
declared,
612+
injectable,
613+
schema,
614+
)
615+
fn = getattr(tool, "fn", None)
616+
if fn is None:
617+
return set(), True, None
618+
try:
619+
declared = {k for k in _INJECTED_KEYS if k in inspect.signature(fn).parameters}
620+
except Exception: # noqa: BLE001 - introspection is best-effort
621+
return set(), True, None
622+
return declared, "llm_model" not in declared, None
594623

595624

596625
def _injected_keys(data: MCPAnalyticsData) -> set:
@@ -609,35 +638,24 @@ def _injected_keys(data: MCPAnalyticsData) -> set:
609638

610639
async def _registry_view(
611640
high_level: Any, name: str, meta: Any
612-
) -> Tuple[Optional[set], Optional[bool]]:
641+
) -> Tuple[Optional[set], Optional[bool], Optional[Dict[str, Any]]]:
613642
"""What the registered tool says about the injected keys: which of
614643
``_INJECTED_KEYS`` it declares itself, and whether a listing would have
615644
injected ``llm_model`` into its schema (the same test the listing applies,
616645
on the schema as the client would see it). Read from the schema (a ``Tool``
617646
subclass may have no function) else the signature. The registry is read
618647
directly, never through middleware, so a cold instance answers without a
619648
listing and rate limiters are not charged. ``(None, None)`` when the
620-
registry has no such tool or cannot be read."""
649+
registry has no such tool or cannot be read. The third item is the tool's
650+
input schema when the registry supplies one."""
621651
try:
622652
tool = await _registered_tool(high_level, name, meta)
623653
except Exception as error: # noqa: BLE001 - introspection is best-effort
624654
if not isinstance(error, _tool_lookup_not_found_errors()):
625655
warn_ownership_lookup_failed(name, error)
626-
return set(_INJECTED_KEYS), None
627-
return None, None
628-
if tool is None:
629-
return None, None
630-
schema = getattr(tool, "parameters", None)
631-
if isinstance(schema, dict):
632-
return _schema_view(schema, dereferenced=_server_dereferences(high_level))
633-
fn = getattr(tool, "fn", None)
634-
if fn is None:
635-
return set(), True
636-
try:
637-
declared = {k for k in _INJECTED_KEYS if k in inspect.signature(fn).parameters}
638-
except Exception: # noqa: BLE001 - introspection is best-effort
639-
return set(), True
640-
return declared, "llm_model" not in declared
656+
return set(_INJECTED_KEYS), None, None
657+
return None, None, None
658+
return _tool_schema_view(high_level, tool)
641659

642660

643661
async def _registered_tool(high_level: Any, name: str, meta: Any) -> Any:
@@ -651,6 +669,29 @@ async def _registered_tool(high_level: Any, name: str, meta: Any) -> Any:
651669
return await high_level.get_tool(name, version=VersionSpec(eq=version))
652670

653671

672+
async def _listed_input_schema(
673+
high_level: Any, name: str, meta: Any
674+
) -> Optional[Dict[str, Any]]:
675+
"""Return the schema that middleware advertises for the current request."""
676+
try:
677+
version = _requested_tool_version(meta)
678+
candidates = [
679+
tool for tool in await high_level.list_tools() if tool.name == name
680+
]
681+
if version is not None:
682+
candidates = [
683+
tool
684+
for tool in candidates
685+
if str(getattr(tool, "version", "")) == version
686+
]
687+
tool = candidates[-1] if candidates else None
688+
schema = getattr(tool, "parameters", None)
689+
except Exception as error: # noqa: BLE001 - analytics must not break dispatch
690+
log(f"PostHog MCP: could not resolve schema for tool {name!r} - {error}")
691+
return None
692+
return schema if isinstance(schema, dict) else None
693+
694+
654695
def _requested_tool_version(meta: Any) -> Optional[str]:
655696
"""Ownership must follow dispatch. Only a FastMCP that exposes the
656697
``_meta`` version extractor its own dispatch uses honours a pinned

‎posthog/mcp/_instrument_v2.py‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
import time
3434
from collections.abc import Mapping
35+
from dataclasses import replace
3536
from typing import Any, Dict, FrozenSet, Optional, Set, Tuple
3637

3738
import mcp.types as mcp_types
@@ -63,6 +64,7 @@
6364
request_meta_from_context,
6465
)
6566
from ._output_instructions import mirror_instructions_into_structured_content
67+
from ._tool_input import get_registered_tool_input_schema
6668
from .logger import log
6769
from .request_headers import get_request_headers
6870
from .session_token import read_mcp_session_header
@@ -230,8 +232,9 @@ def _tool_own_properties_v2(high_level: Any, name: str) -> Dict[str, Any]:
230232
site so checking ownership of both ``context`` and ``conversation_id``
231233
doesn't look the tool up from the manager twice."""
232234
try:
233-
tool = high_level._tool_manager.get_tool(name)
234-
properties = (getattr(tool, "parameters", None) or {}).get("properties")
235+
properties = (get_registered_tool_input_schema(high_level, name) or {}).get(
236+
"properties"
237+
)
235238
except Exception: # noqa: BLE001
236239
return {}
237240
# Fail closed on a malformed schema: the caller does `param in <this>` in the
@@ -318,6 +321,11 @@ async def wrapped(
318321
]
319322
return mcp_types.CallToolResult(content=virtual_content)
320323

324+
lifecycle = replace(
325+
lifecycle,
326+
input_schema=get_registered_tool_input_schema(server, name),
327+
)
328+
321329
# v2 validates against the function signature and rejects unexpected
322330
# keys, so injected parameters are stripped before dispatch — but never
323331
# one the tool's own schema declares (that's a real argument).
@@ -441,7 +449,7 @@ def _requested_tool_version(ctx: Any) -> Optional[str]:
441449

442450
async def _standalone_injected_parameters(
443451
server: Any, data: MCPAnalyticsData, name: str, version: Optional[str]
444-
) -> Optional[FrozenSet[str]]:
452+
) -> Tuple[Optional[FrozenSet[str]], Optional[Dict[str, Any]]]:
445453
"""Resolve ownership in the current request, including middleware and versions.
446454
447455
Listings from other requests can have different application-owned parameters.
@@ -464,9 +472,9 @@ async def _standalone_injected_parameters(
464472
schema = getattr(tool, "parameters", None)
465473
except Exception as error: # noqa: BLE001 - schema lookup must not prevent dispatch
466474
log(f"PostHog MCP: could not resolve schema for tool {name!r} - {error}")
467-
return None
475+
return None, None
468476
if not isinstance(schema, dict):
469-
return None
477+
return None, None
470478
injected = set()
471479
if is_context_enabled(data.options.context):
472480
injected.add("context")
@@ -476,7 +484,10 @@ async def _standalone_injected_parameters(
476484
can_inject_model_parameter(schema)
477485
):
478486
injected.add("llm_model")
479-
return frozenset(key for key in injected if not schema_has_param(schema, key))
487+
return (
488+
frozenset(key for key in injected if not schema_has_param(schema, key)),
489+
schema,
490+
)
480491

481492

482493
def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None:
@@ -492,10 +503,11 @@ async def handler(ctx: Any, params: Any) -> Any:
492503
# reads the self-reported model anyway; only a listing that proved the
493504
# application owns `llm_model` stops it (posthog-js ADR-0011).
494505
analytics_owns_model = data.tool_model_parameter_injected.get(name) is not False
506+
input_schema = None
495507
standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None
496508
if standalone is not None:
497509
version = _requested_tool_version(ctx)
498-
injected = await _standalone_injected_parameters(
510+
injected, input_schema = await _standalone_injected_parameters(
499511
standalone, data, name, version
500512
)
501513
if injected is not None:
@@ -521,6 +533,7 @@ async def handler(ctx: Any, params: Any) -> Any:
521533
client_version=client_version,
522534
protocol_version=protocol_version,
523535
extra={"session_id": mcp_session_id, "ctx": ctx},
536+
input_schema=input_schema,
524537
)
525538

526539
# No tool registry on a raw low-level server, so ownership is settled

0 commit comments

Comments
 (0)