Skip to content

Commit a091101

Browse files
committed
fix(mcp): keep one precedence when both virtual tools share a name
`instrument()` already handled a host that configures `missing_capability_tool_name` and `collect_feedback.tool_name` to the same string: it drops the feedback tool, keeps missing-capability precedence, and warns "duplicate". The `PostHogMCP` dispatcher did neither. `prepare_tool_list` fell through to its "name already taken" branch, so the feedback descriptor went unadvertised with no warning. `prepare_tool_call` then set `is_feedback` and `is_missing_capability` both true, and the README's dispatch snippet tests `is_feedback` first, so every missing-capability call became a bogus $mcp_feedback capture -- the inverse of the precedence the other path sets on purpose. Also folds four copies of the ownership-probe warning into one helper, and names the three collision variants, so a typo is a type error rather than silently picking the "blocked" wording. Generated-By: PostHog Desktop Task-Id: 7047b34a-b562-40ab-ae65-8b0f24700e57
1 parent 49b17f7 commit a091101

6 files changed

Lines changed: 86 additions & 34 deletions

File tree

‎posthog/mcp/_instrument_fastmcp.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
resolve_session_and_client,
4141
start_tool_call_lifecycle,
4242
start_tools_list_lifecycle,
43+
warn_ownership_lookup_failed,
4344
)
4445
from ._internal import MCPAnalyticsData
4546
from ._model_parameters import (
@@ -326,10 +327,7 @@ def _name_owned_by_real_tool(server: Any, name: str) -> Optional[bool]:
326327
try:
327328
return tool_manager.get_tool(name) is not None
328329
except Exception as err: # noqa: BLE001 - analytics must not break the call
329-
log(
330-
f'Warning: could not determine whether "{name}" is a real tool of '
331-
f"yours; delegating the call to your server - {err}"
332-
)
330+
warn_ownership_lookup_failed(name, err)
333331
return None
334332

335333

‎posthog/mcp/_instrument_lowlevel.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
resolve_virtual_tool_injection,
4242
start_tool_call_lifecycle,
4343
start_tools_list_lifecycle,
44+
warn_ownership_lookup_failed,
4445
)
4546
from ._internal import MCPAnalyticsData
4647
from ._model_parameters import request_meta_from_context
@@ -501,10 +502,7 @@ async def _name_owned_by_real_tool(
501502
# -- guessing that would swallow a real tool of theirs. What reaches
502503
# here is the visibility, transform and auth work layered on top of
503504
# the providers; a provider failure never does (see the docstring).
504-
log(
505-
f'Warning: could not determine whether "{name}" is a real tool of '
506-
f"yours; delegating the call to your server - {err}"
507-
)
505+
warn_ownership_lookup_failed(name, err)
508506
return None
509507
# May be None: see `raw_listing_owns_tool_name`. Callers intercept only on a
510508
# definite False.

‎posthog/mcp/_instrument_v2.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
resolve_virtual_tool_injection,
5656
start_tool_call_lifecycle,
5757
start_tools_list_lifecycle,
58+
warn_ownership_lookup_failed,
5859
)
5960
from ._internal import MCPAnalyticsData
6061
from ._model_parameters import (
@@ -737,8 +738,5 @@ def _name_owned_by_real_tool_v2(high_level: Any, name: str) -> Optional[bool]:
737738
try:
738739
return high_level._tool_manager.get_tool(name) is not None
739740
except Exception as err: # noqa: BLE001 - analytics must not break the call
740-
log(
741-
f'Warning: could not determine whether "{name}" is a real tool of '
742-
f"yours; delegating the call to your server - {err}"
743-
)
741+
warn_ownership_lookup_failed(name, err)
744742
return None

‎posthog/mcp/_instrumentation.py‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import threading
1515
from dataclasses import dataclass
1616
from datetime import datetime, timezone
17-
from typing import Any, Dict, List, Optional, Set
17+
from typing import Any, Dict, List, Literal, Optional, Set
1818

1919
from ._capture import capture_event
2020
from ._context_parameters import (
@@ -59,6 +59,10 @@
5959
VIRTUAL_TOOL_MISSING_CAPABILITY = "missing_capability"
6060
VIRTUAL_TOOL_FEEDBACK = "feedback"
6161

62+
# Why a collision happened, which picks the warning's wording. Named so a typo
63+
# is a type error instead of silently getting the "blocked" text.
64+
VirtualToolCollisionVariant = Literal["blocked", "duplicate", "shadowed"]
65+
6266
# The option that renames each virtual tool, quoted verbatim in the collision
6367
# warnings.
6468
_VIRTUAL_TOOL_RENAME_OPTION = {
@@ -875,16 +879,26 @@ async def raw_listing_owns_tool_name(
875879
try:
876880
names = await probe(ctx)
877881
except Exception as err: # noqa: BLE001 - analytics must not break the call
878-
log(
879-
f'Warning: could not determine whether "{name}" is a real tool of '
880-
f"yours; delegating the call to your server - {err}"
881-
)
882+
warn_ownership_lookup_failed(name, err)
882883
return None
883884
return None if names is None else name in names
884885

885886

887+
def warn_ownership_lookup_failed(name: str, err: Exception) -> None:
888+
"""A tool-ownership lookup raised instead of answering. Every adapter's probe
889+
reports it the same way and then returns ``None``, so the call is delegated
890+
to the host rather than intercepted on a guess."""
891+
log(
892+
f'Warning: could not determine whether "{name}" is a real tool of '
893+
f"yours; delegating the call to your server - {err}"
894+
)
895+
896+
886897
def _warn_virtual_tool_collision(
887-
data: MCPAnalyticsData, kind: str, name: str, variant: str
898+
data: MCPAnalyticsData,
899+
kind: str,
900+
name: str,
901+
variant: VirtualToolCollisionVariant,
888902
) -> None:
889903
"""Warn once per ``(kind, name, variant)`` for the life of the server's
890904
tracking state, so a client that re-lists tools on every turn doesn't flood
@@ -897,7 +911,11 @@ def _warn_virtual_tool_collision(
897911

898912

899913
def virtual_tool_collision_message(
900-
kind: str, name: str, variant: str, *, rename_option: Optional[str] = None
914+
kind: str,
915+
name: str,
916+
variant: VirtualToolCollisionVariant,
917+
*,
918+
rename_option: Optional[str] = None,
901919
) -> str:
902920
"""The warning text for a virtual-tool name collision. Always names the option
903921
that renames PostHog's tool -- a warning without its own remedy gets ignored.

‎posthog/mcp/posthog_mcp.py‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ._instrumentation import (
2828
VIRTUAL_TOOL_FEEDBACK,
2929
VIRTUAL_TOOL_MISSING_CAPABILITY,
30+
VirtualToolCollisionVariant,
3031
drain_pending_sync,
3132
fire_and_forget,
3233
virtual_tool_collision_message,
@@ -398,38 +399,42 @@ def prepare_tool_list(
398399
# straight back. Warning about that would be warning about ourselves.
399400
if report_missing:
400401
name = self._missing_capability_tool_name
401-
if self._real_tool_owns_name(prepared, name):
402+
existing = _find_tool(prepared, name)
403+
if existing is not None and not self._is_sdk_virtual_tool(existing):
402404
self._warn_virtual_tool_collision(
403405
VIRTUAL_TOOL_MISSING_CAPABILITY,
404406
name,
405407
'PostHogMCP(missing_capability_tool_name="...")',
406408
)
407-
elif not any(_tool_name(t) == name for t in prepared):
409+
elif existing is None:
408410
prepared.append(build_report_missing_descriptor(name))
409411
if collect_feedback and self._collect_feedback is not None:
410412
name = self._feedback_tool_name
411-
if self._real_tool_owns_name(prepared, name):
413+
existing = _find_tool(prepared, name)
414+
# Both virtual tools under one name: missing-capability wins in
415+
# `prepare_tool_call`, so advertising this one too would dead-letter
416+
# the feedback path. Same precedence as `instrument()`.
417+
duplicate = name == self._missing_capability_tool_name
418+
if duplicate or (
419+
existing is not None and not self._is_sdk_virtual_tool(existing)
420+
):
412421
self._warn_virtual_tool_collision(
413422
VIRTUAL_TOOL_FEEDBACK,
414423
name,
415424
'PostHogMCP(collect_feedback=CollectFeedbackOptions(tool_name="..."))',
425+
variant="duplicate" if duplicate else "blocked",
416426
)
417-
elif not any(_tool_name(t) == name for t in prepared):
427+
elif existing is None:
418428
prepared.append(get_feedback_tool_descriptor(self._collect_feedback))
419429
prepared = self._inject_models(prepared)
420430
return prepared
421431

422-
def _real_tool_owns_name(self, prepared: List[Any], name: str) -> bool:
423-
"""Whether a *host* tool in this listing owns ``name``. Our own
424-
descriptor doesn't count: a host may re-prepare an already-prepared
425-
list, and that is not a collision to warn about."""
426-
return any(
427-
_tool_name(tool) == name and not self._is_sdk_virtual_tool(tool)
428-
for tool in prepared
429-
)
430-
431432
def _warn_virtual_tool_collision(
432-
self, kind: str, name: str, rename_option: str
433+
self,
434+
kind: str,
435+
name: str,
436+
rename_option: str,
437+
variant: VirtualToolCollisionVariant = "blocked",
433438
) -> None:
434439
"""Warn once per ``(kind, name)`` for this client's lifetime, so a host
435440
that prepares a listing on every request doesn't flood the log."""
@@ -439,7 +444,7 @@ def _warn_virtual_tool_collision(
439444
self._warned_virtual_tool_collisions.add(key)
440445
warn(
441446
virtual_tool_collision_message(
442-
kind, name, "blocked", rename_option=rename_option
447+
kind, name, variant, rename_option=rename_option
443448
)
444449
)
445450

@@ -489,9 +494,13 @@ def prepare_tool_call(
489494
# the real tool wins — the stateless twin of the ownership check
490495
# instrument() runs. Without it the name match stands, and the
491496
# documented remedy for a collision is renaming PostHog's tool.
497+
# The name check against missing-capability keeps one precedence when
498+
# both tools share a name: without it both flags are true, and a
499+
# dispatcher testing `is_feedback` first misroutes every call.
492500
is_feedback = (
493501
self._collect_feedback is not None
494502
and name == self._feedback_tool_name
503+
and name != self._missing_capability_tool_name
495504
and original_tool is None
496505
)
497506
# Same guard for the missing-capability tool. Unlike feedback it has no
@@ -690,6 +699,12 @@ def _tool_name(tool: Any) -> Optional[str]:
690699
return getattr(tool, "name", None)
691700

692701

702+
def _find_tool(prepared: List[Any], name: str) -> Optional[Any]:
703+
"""The listed tool using ``name``, or ``None``. One pass answers both "is
704+
the name taken" and "is the tool holding it ours"."""
705+
return next((tool for tool in prepared if _tool_name(tool) == name), None)
706+
707+
693708
def _tool_schema(tool: Any) -> Optional[Dict[str, Any]]:
694709
if isinstance(tool, dict):
695710
schema = tool.get("inputSchema")

‎posthog/test/mcp/test_feedback.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,31 @@ async def test_posthogmcp_prepare_tool_call_without_opt_in_never_flags():
948948
assert call.is_feedback is False and call.feedback_report is None
949949

950950

951+
async def test_posthogmcp_one_name_for_both_virtual_tools(caplog):
952+
# Both virtual tools configured with the same name. Missing-capability wins
953+
# every call path, so advertising the feedback tool too would dead-letter it
954+
# — the stateless twin of the "duplicate" drop `instrument()` performs.
955+
client, _ = make_client(
956+
missing_capability_tool_name="assist",
957+
collect_feedback=CollectFeedbackOptions(tool_name="assist"),
958+
)
959+
tools = [{"name": "search", "inputSchema": {"type": "object", "properties": {}}}]
960+
961+
with caplog.at_level("WARNING", logger="posthog.mcp"):
962+
prepared = client.prepare_tool_list(
963+
tools, report_missing=True, collect_feedback=True
964+
)
965+
966+
assert [t["name"] for t in prepared] == ["search", "assist"]
967+
assert any("both" in r.message for r in caplog.records if r.name == "posthog.mcp")
968+
969+
# A dispatcher testing `is_feedback` first must not swallow the call: the
970+
# name belongs to missing-capability, so only that flag is set.
971+
call = client.prepare_tool_call("assist", {"context": "need csv export"})
972+
assert call.is_missing_capability is True
973+
assert call.is_feedback is False and call.feedback_report is None
974+
975+
951976
async def test_posthogmcp_original_tool_wins_name_collision():
952977
# A host whose own list holds a real `send_feedback` tool passes it as
953978
# `original_tool`; the call then dispatches as a real tool call instead of

0 commit comments

Comments
 (0)