Skip to content

Commit 57a3d9a

Browse files
authored
fix(mcp): scope input schemas to tool calls
1 parent d5a28c0 commit 57a3d9a

8 files changed

Lines changed: 173 additions & 70 deletions

File tree

‎posthog/mcp/_instrument_fastmcp.py‎

Lines changed: 13 additions & 1 deletion
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
@@ -131,6 +132,8 @@ async def wrapped(
131132
for text in lifecycle.virtual_result_texts(reply)
132133
]
133134

135+
lifecycle = replace(lifecycle, input_schema=_tool_input_schema(server, name))
136+
134137
# Strip each injected key independently. A tool can declare its own
135138
# `context` (kept) while `conversation_id` is still SDK-injected (stripped),
136139
# so coupling both to context-ownership leaked conversation_id into the tool.
@@ -272,7 +275,7 @@ async def list_handler(req: Any) -> Any:
272275
duration_ms = (time.monotonic() - start) * 1000
273276
tools = extract_tools(result)
274277
# Empty is computed before adding the virtual missing-capability tool.
275-
names, empty = collect_listed_tools(data, tools, lifecycle.session_id)
278+
names, empty = collect_listed_tools(data, tools)
276279
injection = resolve_virtual_tool_injection(
277280
data,
278281
tools,
@@ -335,6 +338,15 @@ def _name_owned_by_real_tool(server: Any, name: str) -> Optional[bool]:
335338
return None
336339

337340

341+
def _tool_input_schema(server: Any, name: str) -> Optional[Dict[str, Any]]:
342+
"""Return the schema from the tool that this server will call."""
343+
try:
344+
schema = server._tool_manager.get_tool(name).parameters
345+
except Exception: # noqa: BLE001 - analytics must not break the call
346+
return None
347+
return schema if isinstance(schema, dict) else None
348+
349+
338350
def _tool_owns_param(server: Any, name: str, param: str) -> bool:
339351
"""True when the tool's own function declares ``param`` — then it's a real tool
340352
argument we must neither inject nor strip (the agent's value belongs to the tool)."""

‎posthog/mcp/_instrument_lowlevel.py‎

Lines changed: 40 additions & 25 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 (
@@ -472,7 +473,7 @@ async def handler(req: Any) -> Any:
472473

473474
# Zero advertised tools is treated as an errored tools/list before the
474475
# virtual missing-capability tool is appended.
475-
names, empty = collect_listed_tools(data, tools, lifecycle.session_id)
476+
names, empty = collect_listed_tools(data, tools)
476477
injection = resolve_virtual_tool_injection(
477478
data,
478479
tools,
@@ -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,41 @@ 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 = None
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+
declared, injectable = _schema_view(
609+
schema, dereferenced=_server_dereferences(high_level)
610+
)
611+
return declared, injectable, schema
612+
fn = getattr(tool, "fn", None)
613+
if fn is None:
614+
return set(), True, None
615+
try:
616+
declared = {k for k in _INJECTED_KEYS if k in inspect.signature(fn).parameters}
617+
except Exception: # noqa: BLE001 - introspection is best-effort
618+
return set(), True, None
619+
return declared, "llm_model" not in declared, None
594620

595621

596622
def _injected_keys(data: MCPAnalyticsData) -> set:
@@ -609,35 +635,24 @@ def _injected_keys(data: MCPAnalyticsData) -> set:
609635

610636
async def _registry_view(
611637
high_level: Any, name: str, meta: Any
612-
) -> Tuple[Optional[set], Optional[bool]]:
638+
) -> Tuple[Optional[set], Optional[bool], Optional[Dict[str, Any]]]:
613639
"""What the registered tool says about the injected keys: which of
614640
``_INJECTED_KEYS`` it declares itself, and whether a listing would have
615641
injected ``llm_model`` into its schema (the same test the listing applies,
616642
on the schema as the client would see it). Read from the schema (a ``Tool``
617643
subclass may have no function) else the signature. The registry is read
618644
directly, never through middleware, so a cold instance answers without a
619645
listing and rate limiters are not charged. ``(None, None)`` when the
620-
registry has no such tool or cannot be read."""
646+
registry has no such tool or cannot be read. The third item is the tool's
647+
input schema when the registry supplies one."""
621648
try:
622649
tool = await _registered_tool(high_level, name, meta)
623650
except Exception as error: # noqa: BLE001 - introspection is best-effort
624651
if not isinstance(error, _tool_lookup_not_found_errors()):
625652
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
653+
return set(_INJECTED_KEYS), None, None
654+
return None, None, None
655+
return _tool_schema_view(high_level, tool)
641656

642657

643658
async def _registered_tool(high_level: Any, name: str, meta: Any) -> Any:

‎posthog/mcp/_instrument_v2.py‎

Lines changed: 23 additions & 8 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
@@ -230,8 +231,7 @@ def _tool_own_properties_v2(high_level: Any, name: str) -> Dict[str, Any]:
230231
site so checking ownership of both ``context`` and ``conversation_id``
231232
doesn't look the tool up from the manager twice."""
232233
try:
233-
tool = high_level._tool_manager.get_tool(name)
234-
properties = (getattr(tool, "parameters", None) or {}).get("properties")
234+
properties = (_tool_input_schema_v2(high_level, name) or {}).get("properties")
235235
except Exception: # noqa: BLE001
236236
return {}
237237
# Fail closed on a malformed schema: the caller does `param in <this>` in the
@@ -240,6 +240,14 @@ def _tool_own_properties_v2(high_level: Any, name: str) -> Dict[str, Any]:
240240
return properties if isinstance(properties, dict) else {}
241241

242242

243+
def _tool_input_schema_v2(high_level: Any, name: str) -> Optional[Dict[str, Any]]:
244+
try:
245+
schema = high_level._tool_manager.get_tool(name).parameters
246+
except Exception: # noqa: BLE001 - analytics must not break the call
247+
return None
248+
return schema if isinstance(schema, dict) else None
249+
250+
243251
def _tool_owns_param_v2(high_level: Any, name: str, param: str) -> bool:
244252
"""Whether the tool's own JSON schema declares ``param`` — then it's a real
245253
tool argument we must neither inject over nor strip. Read from the tool's
@@ -318,6 +326,8 @@ async def wrapped(
318326
]
319327
return mcp_types.CallToolResult(content=virtual_content)
320328

329+
lifecycle = replace(lifecycle, input_schema=_tool_input_schema_v2(server, name))
330+
321331
# v2 validates against the function signature and rejects unexpected
322332
# keys, so injected parameters are stripped before dispatch — but never
323333
# one the tool's own schema declares (that's a real argument).
@@ -441,7 +451,7 @@ def _requested_tool_version(ctx: Any) -> Optional[str]:
441451

442452
async def _standalone_injected_parameters(
443453
server: Any, data: MCPAnalyticsData, name: str, version: Optional[str]
444-
) -> Optional[FrozenSet[str]]:
454+
) -> Tuple[Optional[FrozenSet[str]], Optional[Dict[str, Any]]]:
445455
"""Resolve ownership in the current request, including middleware and versions.
446456
447457
Listings from other requests can have different application-owned parameters.
@@ -464,9 +474,9 @@ async def _standalone_injected_parameters(
464474
schema = getattr(tool, "parameters", None)
465475
except Exception as error: # noqa: BLE001 - schema lookup must not prevent dispatch
466476
log(f"PostHog MCP: could not resolve schema for tool {name!r} - {error}")
467-
return None
477+
return None, None
468478
if not isinstance(schema, dict):
469-
return None
479+
return None, None
470480
injected = set()
471481
if is_context_enabled(data.options.context):
472482
injected.add("context")
@@ -476,7 +486,10 @@ async def _standalone_injected_parameters(
476486
can_inject_model_parameter(schema)
477487
):
478488
injected.add("llm_model")
479-
return frozenset(key for key in injected if not schema_has_param(schema, key))
489+
return (
490+
frozenset(key for key in injected if not schema_has_param(schema, key)),
491+
schema,
492+
)
480493

481494

482495
def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None:
@@ -492,10 +505,11 @@ async def handler(ctx: Any, params: Any) -> Any:
492505
# reads the self-reported model anyway; only a listing that proved the
493506
# application owns `llm_model` stops it (posthog-js ADR-0011).
494507
analytics_owns_model = data.tool_model_parameter_injected.get(name) is not False
508+
input_schema = None
495509
standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None
496510
if standalone is not None:
497511
version = _requested_tool_version(ctx)
498-
injected = await _standalone_injected_parameters(
512+
injected, input_schema = await _standalone_injected_parameters(
499513
standalone, data, name, version
500514
)
501515
if injected is not None:
@@ -521,6 +535,7 @@ async def handler(ctx: Any, params: Any) -> Any:
521535
client_version=client_version,
522536
protocol_version=protocol_version,
523537
extra={"session_id": mcp_session_id, "ctx": ctx},
538+
input_schema=input_schema,
524539
)
525540

526541
# No tool registry on a raw low-level server, so ownership is settled
@@ -695,7 +710,7 @@ async def handler(ctx: Any, params: Any) -> Any:
695710

696711
tools = list(getattr(result, "tools", []) or [])
697712
# Empty is computed before adding the virtual missing-capability tool.
698-
names, empty = collect_listed_tools(data, tools, lifecycle.session_id)
713+
names, empty = collect_listed_tools(data, tools)
699714
injection = resolve_virtual_tool_injection(
700715
data, tools, is_first_page=is_first_listing_page(params)
701716
)

‎posthog/mcp/_instrumentation.py‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ class ToolCallLifecycle:
448448
feedback_name: Optional[str]
449449
conversation_id: Optional[str]
450450
minted_conversation_id: bool
451+
input_schema: Any
451452

452453
@property
453454
def is_missing_capability(self) -> bool:
@@ -547,6 +548,7 @@ async def record_error(self, error: Any, duration_ms: float) -> None:
547548
protocol_version=self.protocol_version,
548549
conversation_id=conversation_id,
549550
extra=self.extra,
551+
input_schema=self.input_schema,
550552
)
551553

552554
async def record_result(
@@ -568,6 +570,7 @@ async def record_result(
568570
protocol_version=self.protocol_version,
569571
conversation_id=conversation_id,
570572
extra=self.extra,
573+
input_schema=self.input_schema,
571574
)
572575

573576

@@ -584,6 +587,7 @@ def start_tool_call_lifecycle(
584587
client_version: Optional[str],
585588
protocol_version: Optional[str],
586589
extra: Dict[str, Any],
590+
input_schema: Any = None,
587591
) -> ToolCallLifecycle:
588592
"""Resolve adapter-independent policy for a tool call without dispatching it."""
589593
enabled = enabled_virtual_tool_names(data)
@@ -617,6 +621,7 @@ def start_tool_call_lifecycle(
617621
feedback_name=feedback_name,
618622
conversation_id=conversation_id,
619623
minted_conversation_id=minted,
624+
input_schema=input_schema,
620625
)
621626

622627

@@ -636,6 +641,7 @@ async def record_tool_call(
636641
protocol_version: Optional[str] = None,
637642
conversation_id: Optional[str] = None,
638643
extra: Optional[Dict[str, Any]] = None,
644+
input_schema: Any = None,
639645
) -> None:
640646
# Analytics must never change what the tool returns or raises: any failure
641647
# building/publishing the event is logged and swallowed here.
@@ -684,12 +690,11 @@ async def record_tool_call(
684690
input_aliases = data.options.resolve_input_aliases(name)
685691
except Exception as err: # noqa: BLE001 - analytics callbacks are isolated
686692
log(f"Warning: resolve_input_aliases failed for tool {name}: {err}")
687-
schema = data.tool_input_schemas.get(session_id, {}).get(name)
688693
event["properties"] = {
689694
**(props or {}),
690695
**get_tool_input_properties(
691696
arguments or {},
692-
schema,
697+
input_schema,
693698
ToolInputOptions(
694699
should_record_input_key=data.options.should_record_input_key,
695700
input_aliases=input_aliases,
@@ -1008,29 +1013,16 @@ def read_tool_category(tool: Any) -> Optional[str]:
10081013
return None
10091014

10101015

1011-
def collect_listed_tools(
1012-
data: MCPAnalyticsData, tools: list, session_id: Optional[str] = None
1013-
) -> tuple[List[str], bool]:
1016+
def collect_listed_tools(data: MCPAnalyticsData, tools: list) -> tuple[List[str], bool]:
10141017
"""Cache common tool metadata and return the pre-injection listing summary."""
10151018
names = []
1016-
schemas = data.tool_input_schemas.get(session_id, {}) if session_id else None
10171019
for tool in tools:
10181020
names.append(tool.name)
10191021
if getattr(tool, "description", None):
10201022
data.tool_descriptions[tool.name] = tool.description
10211023
category = read_tool_category(tool)
10221024
if category:
10231025
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)
10341026
return names, not tools
10351027

10361028

‎posthog/mcp/_internal.py‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,6 @@ 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-
)
9691
# True only when PostHog added llm_model to this tool's advertised schema.
9792
# Missing/False fails closed so an application-owned field is never read or stripped.
9893
tool_model_parameter_injected: Dict[str, bool] = field(default_factory=dict)

0 commit comments

Comments
 (0)