Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/mcp-input-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

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.
29 changes: 29 additions & 0 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,35 @@ from posthog.mcp import MCPAnalyticsOptions, instrument
instrument(server, posthog, MCPAnalyticsOptions(capture_model=False, enable_conversation_id=False))
```

## Capture safe input field names

Tool-call events include `$mcp_input_keys`. The SDK records names from the
server's input schema. It replaces unknown names with one `[redacted]` entry.
Argument values do not affect this property.

Use `resolve_input_aliases` when a tool accepts alternative names. The map uses
each canonical name as a key. Its value lists accepted aliases in server order.

```python
instrument(
server,
posthog,
MCPAnalyticsOptions(
resolve_input_aliases=lambda tool_name: (
{"location": ["city", "place"]}
if tool_name == "weather-current"
else None
)
),
)
```

The SDK records `city` in `$mcp_input_keys`. It also records
`city:location` in `$mcp_input_aliases_used`. The SDK does not change the call.

Custom dispatchers can call `get_tool_input_properties()` and add its result to
the `properties` argument of `capture_tool_call()`.

Model capture adds an `llm_model` argument to compatible tool schemas, required on the official
high-level adapters and optional elsewhere. Dispatch never enforces it, so servers keep working;
strict-schema clients see the new field. Set `capture_model=False` to leave schemas untouched.
Expand Down
8 changes: 8 additions & 0 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
)
from ._event_types import MCPAnalyticsEventType
from ._instrumentation import drain_pending
from ._tool_input import get_tool_input_properties
from ._internal import (
MCPAnalyticsData,
get_server_tracking_data,
Expand Down Expand Up @@ -81,11 +82,14 @@
CaptureEventData,
CollectFeedbackOptions,
FeedbackReport,
InputAliasMap,
MCPAnalyticsContextOptions,
MCPAnalyticsModelOptions,
MCPAnalyticsModelSource,
MCPAnalyticsOptions,
PreparedToolCall,
ShouldRecordInputKeyFn,
ToolInputOptions,
UserIdentity,
)
from .version import __version__
Expand All @@ -102,7 +106,11 @@
"CaptureEventData",
"CollectFeedbackOptions",
"FeedbackReport",
"InputAliasMap",
"PreparedToolCall",
"ShouldRecordInputKeyFn",
"ToolInputOptions",
"get_tool_input_properties",
"get_more_tools_result",
"send_feedback_result",
"SEND_FEEDBACK_TOOL_NAME",
Expand Down
12 changes: 12 additions & 0 deletions posthog/mcp/_instrument_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import inspect
import time
from dataclasses import replace
from typing import Any, Dict, Optional, Tuple

import mcp.types as mcp_types
Expand Down Expand Up @@ -132,6 +133,8 @@ async def wrapped(
for text in lifecycle.virtual_result_texts(reply)
]

lifecycle = replace(lifecycle, input_schema=_tool_input_schema(server, name))

# Strip each injected key independently. A tool can declare its own
# `context` (kept) while `conversation_id` is still SDK-injected (stripped),
# so coupling both to context-ownership leaked conversation_id into the tool.
Expand Down Expand Up @@ -336,6 +339,15 @@ def _name_owned_by_real_tool(server: Any, name: str) -> Optional[bool]:
return None


def _tool_input_schema(server: Any, name: str) -> Optional[Dict[str, Any]]:
"""Return the schema from the tool that this server will call."""
try:
schema = server._tool_manager.get_tool(name).parameters
except Exception: # noqa: BLE001 - analytics must not break the call
return None
return schema if isinstance(schema, dict) else None


def _tool_owns_param(server: Any, name: str, param: str) -> bool:
"""True when the tool's own function declares ``param`` — then it's a real tool
argument we must neither inject nor strip (the agent's value belongs to the tool)."""
Expand Down
63 changes: 39 additions & 24 deletions posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ def _wrap_call_tool(
async def handler(req: Any) -> Any:
name = req.params.name
arguments = dict(req.params.arguments or {})
strip, model_ours = (
strip, model_ours, input_schema = (
await _standalone_ownership(data, high_level, name, req.params.meta)
if strip_injected
else (set(), data.tool_model_parameter_injected.get(name))
else (set(), data.tool_model_parameter_injected.get(name), None)
)
client_name, client_version = _client_info(server)
protocol_version = _protocol_version(server)
Expand All @@ -213,6 +213,7 @@ async def handler(req: Any) -> Any:
client_version=client_version,
protocol_version=protocol_version,
extra={"session_id": mcp_session_id, "ctx": _request_context(server)},
input_schema=input_schema,
)

if lifecycle.is_missing_capability and (
Expand Down Expand Up @@ -566,10 +567,11 @@ def _tool_lookup_not_found_errors() -> Tuple[type, ...]:

async def _standalone_ownership(
data: MCPAnalyticsData, high_level: Any, name: str, meta: Any
) -> Tuple[set, Optional[bool]]:
) -> Tuple[set, Optional[bool], Optional[Dict[str, Any]]]:
"""Ownership of the injected arguments on jlowin's standalone FastMCP: the
keys to strip before it validates the call, and whether ``llm_model`` is
ours (``None`` when nothing can say).
ours (``None`` when nothing can say). The third item is the trusted schema
for input-name analytics, or ``None`` when middleware can change dispatch.

Only keys injected under the current options are candidates. ``context``
and ``conversation_id`` are stripped unless the registered schema (or,
Expand All @@ -581,17 +583,41 @@ async def _standalone_ownership(
stays and is still read (posthog-js ADR-0011).
"""
try:
declared, model_injectable = await _registry_view(high_level, name, meta)
declared, model_injectable, input_schema = await _registry_view(
high_level, name, meta
)
model_ours = data.tool_model_parameter_injected.get(name, model_injectable)
if _dispatch_can_differ(high_level):
model_ours = False
input_schema = None
except Exception: # noqa: BLE001 - ownership inference must never prevent dispatch
declared, model_ours = None, None
declared, model_ours, input_schema = None, None, None
candidates = _injected_keys(data)
strip = {k for k in candidates - {"llm_model"} if k not in (declared or set())}
if "llm_model" in candidates and model_ours:
strip.add("llm_model")
return strip, model_ours
return strip, model_ours, input_schema


def _tool_schema_view(
high_level: Any, tool: Any
) -> Tuple[Optional[set], Optional[bool], Optional[Dict[str, Any]]]:
if tool is None:
return None, None, None
schema = getattr(tool, "parameters", None)
if isinstance(schema, dict):
declared, injectable = _schema_view(
schema, dereferenced=_server_dereferences(high_level)
)
return declared, injectable, schema
fn = getattr(tool, "fn", None)
if fn is None:
return set(), True, None
try:
declared = {k for k in _INJECTED_KEYS if k in inspect.signature(fn).parameters}
except Exception: # noqa: BLE001 - introspection is best-effort
return set(), True, None
return declared, "llm_model" not in declared, None


def _injected_keys(data: MCPAnalyticsData) -> set:
Expand All @@ -610,35 +636,24 @@ def _injected_keys(data: MCPAnalyticsData) -> set:

async def _registry_view(
high_level: Any, name: str, meta: Any
) -> Tuple[Optional[set], Optional[bool]]:
) -> Tuple[Optional[set], Optional[bool], Optional[Dict[str, Any]]]:
"""What the registered tool says about the injected keys: which of
``_INJECTED_KEYS`` it declares itself, and whether a listing would have
injected ``llm_model`` into its schema (the same test the listing applies,
on the schema as the client would see it). Read from the schema (a ``Tool``
subclass may have no function) else the signature. The registry is read
directly, never through middleware, so a cold instance answers without a
listing and rate limiters are not charged. ``(None, None)`` when the
registry has no such tool or cannot be read."""
registry has no such tool or cannot be read. The third item is the tool's
input schema when the registry supplies one."""
try:
tool = await _registered_tool(high_level, name, meta)
except Exception as error: # noqa: BLE001 - introspection is best-effort
if not isinstance(error, _tool_lookup_not_found_errors()):
warn_ownership_lookup_failed(name, error)
return set(_INJECTED_KEYS), None
return None, None
if tool is None:
return None, None
schema = getattr(tool, "parameters", None)
if isinstance(schema, dict):
return _schema_view(schema, dereferenced=_server_dereferences(high_level))
fn = getattr(tool, "fn", None)
if fn is None:
return set(), True
try:
declared = {k for k in _INJECTED_KEYS if k in inspect.signature(fn).parameters}
except Exception: # noqa: BLE001 - introspection is best-effort
return set(), True
return declared, "llm_model" not in declared
return set(_INJECTED_KEYS), None, None
return None, None, None
return _tool_schema_view(high_level, tool)


async def _registered_tool(high_level: Any, name: str, meta: Any) -> Any:
Expand Down
33 changes: 26 additions & 7 deletions posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

import time
from collections.abc import Mapping
from dataclasses import replace
from typing import Any, Dict, FrozenSet, Optional, Set, Tuple

import mcp.types as mcp_types
Expand Down Expand Up @@ -231,8 +232,7 @@ def _tool_own_properties_v2(high_level: Any, name: str) -> Dict[str, Any]:
site so checking ownership of both ``context`` and ``conversation_id``
doesn't look the tool up from the manager twice."""
try:
tool = high_level._tool_manager.get_tool(name)
properties = (getattr(tool, "parameters", None) or {}).get("properties")
properties = (_tool_input_schema_v2(high_level, name) or {}).get("properties")
except Exception: # noqa: BLE001
return {}
# Fail closed on a malformed schema: the caller does `param in <this>` in the
Expand All @@ -241,6 +241,16 @@ def _tool_own_properties_v2(high_level: Any, name: str) -> Dict[str, Any]:
return properties if isinstance(properties, dict) else {}


def _tool_input_schema_v2(
high_level: Any, name: str
) -> Optional[Dict[str, Any]]:
try:
schema = high_level._tool_manager.get_tool(name).parameters
except Exception: # noqa: BLE001 - analytics must not break the call
return None
return schema if isinstance(schema, dict) else None


def _tool_owns_param_v2(high_level: Any, name: str, param: str) -> bool:
"""Whether the tool's own JSON schema declares ``param`` — then it's a real
tool argument we must neither inject over nor strip. Read from the tool's
Expand Down Expand Up @@ -319,6 +329,10 @@ async def wrapped(
]
return mcp_types.CallToolResult(content=virtual_content)

lifecycle = replace(
lifecycle, input_schema=_tool_input_schema_v2(server, name)
)

# v2 validates against the function signature and rejects unexpected
# keys, so injected parameters are stripped before dispatch — but never
# one the tool's own schema declares (that's a real argument).
Expand Down Expand Up @@ -442,7 +456,7 @@ def _requested_tool_version(ctx: Any) -> Optional[str]:

async def _standalone_injected_parameters(
server: Any, data: MCPAnalyticsData, name: str, version: Optional[str]
) -> Optional[FrozenSet[str]]:
) -> Tuple[Optional[FrozenSet[str]], Optional[Dict[str, Any]]]:
"""Resolve ownership in the current request, including middleware and versions.

Listings from other requests can have different application-owned parameters.
Expand All @@ -465,9 +479,9 @@ async def _standalone_injected_parameters(
schema = getattr(tool, "parameters", None)
except Exception as error: # noqa: BLE001 - schema lookup must not prevent dispatch
log(f"PostHog MCP: could not resolve schema for tool {name!r} - {error}")
return None
return None, None
if not isinstance(schema, dict):
return None
return None, None
injected = set()
if is_context_enabled(data.options.context):
injected.add("context")
Expand All @@ -477,7 +491,10 @@ async def _standalone_injected_parameters(
can_inject_model_parameter(schema)
):
injected.add("llm_model")
return frozenset(key for key in injected if not schema_has_param(schema, key))
return (
frozenset(key for key in injected if not schema_has_param(schema, key)),
schema,
)


def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None:
Expand All @@ -493,10 +510,11 @@ async def handler(ctx: Any, params: Any) -> Any:
# reads the self-reported model anyway; only a listing that proved the
# application owns `llm_model` stops it (posthog-js ADR-0011).
analytics_owns_model = data.tool_model_parameter_injected.get(name) is not False
input_schema = None
standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None
if standalone is not None:
version = _requested_tool_version(ctx)
injected = await _standalone_injected_parameters(
injected, input_schema = await _standalone_injected_parameters(
standalone, data, name, version
)
if injected is not None:
Expand All @@ -522,6 +540,7 @@ async def handler(ctx: Any, params: Any) -> Any:
client_version=client_version,
protocol_version=protocol_version,
extra={"session_id": mcp_session_id, "ctx": ctx},
input_schema=input_schema,
)

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