Skip to content
Open
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
11 changes: 11 additions & 0 deletions .sampo/changesets/mcp-virtual-tool-first-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
pypi/posthog: patch
---

Fix MCP analytics virtual tool injection on a paginated `tools/list`. `get_more_tools` was added to every page and `send_feedback` only to the last; both now go on the first page, matching `@posthog/mcp`.

- A real tool of yours sharing a virtual tool's name now wins instead of being silently swallowed, and the warning names the option that renames PostHog's — `missing_capability_tool_name` or `collect_feedback`'s `tool_name`. Warnings also reach the `posthog.mcp` logger, so you see them without setting the `logger` option.
- If PostHog cannot tell whether a name is yours or its own, the call is delegated to your server rather than intercepted.
- A server that returns the same `tools/list` result object on every request no longer has PostHog's injected tool read back as a collision, which stopped both tools being advertised from the second listing on.

On a paginated listing, the first page's `$mcp_tools_list.listed_tool_names` now carries the virtual tools even when a next page follows.
68 changes: 66 additions & 2 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ and adds no tools.
The tool covers what `report_missing` covers (as feedback_type
`missing_capability`), so new integrations should enable only one of the two.

If a real tool already uses the name, the SDK logs a warning, does not inject
the virtual tool, and never intercepts the real tool.
See [Virtual tools on a paginated `tools/list`](#virtual-tools-on-a-paginated-toolslist)
for name collisions and the page rule.

Use the object form to rename the tool, declare host-specific fields, or route
reports to a real backend:
Expand Down Expand Up @@ -184,6 +184,70 @@ if call.is_feedback:
`on_feedback` is ignored on this path — the dispatcher routes reports itself via
`call.feedback_report`.

On this path you own the page rule, because you pass the switches per call. Pass
them for the first page only, and pass your own tool as `original_tool` so a real
tool by a virtual tool's name wins:

```python
first_page = request.params.get("cursor") is None
tools = posthog.prepare_tool_list(
page_tools, report_missing=first_page, collect_feedback=first_page
)

call = posthog.prepare_tool_call(
tool_name, raw_args, original_tool=my_tools.get(tool_name)
)
```

## Virtual tools on a paginated `tools/list`

`instrument()` advertises up to two tools of its own: `get_more_tools`
(`report_missing=True`) and `send_feedback` (`collect_feedback=True`).

A client concatenates every `tools/list` page into one list, so each virtual tool
is appended to the **first page only** — the page every client reads, including
clients that never follow `nextCursor`. "First page" means a request with no
cursor; an empty string is a valid opaque cursor, so `cursor: ""` is a
continuation page.

### Tool name collisions

Your tools win when the SDK can see them. Rename the SDK's to keep both:

```python
MCPAnalyticsOptions(
report_missing=True,
missing_capability_tool_name="find_posthog_tools",
collect_feedback=CollectFeedbackOptions(tool_name="tell_posthog"),
)
```

- A real tool using the name **on the first page** wins: the SDK warns, injects
nothing, and never intercepts yours.
- A real tool that appears **only on a later page** is shadowed — page one cannot
see page two, so the SDK's tool is already advertised and calls to the name
reach it. The SDK warns when that page is served. `@posthog/mcp` is the same.
- Configuring **both** virtual tools with one name advertises only
`get_more_tools`, and warns.

Warnings go to the `logger` option *and* the `posthog.mcp` standard-library
logger, so a default-configured host sees them on stderr.

At call time the SDK checks ownership again, covering the process that serves a
call without having served a listing — the ordinary multi-pod case. FastMCP and
v2 `MCPServer` are asked via their tool registry; a raw low-level server has
none, so the SDK calls your own `tools/list` handler, once per call to a virtual
tool's name and never for ordinary traffic. If that check cannot answer — a
registry lookup or a listing handler raised, or you registered `tools/list`
after `instrument()` — the call is delegated to your server rather than
intercepted, and the reason is logged: guessing the other way would swallow a
real tool of yours silently.

This check is the only ownership signal used at call time, so a server that
serves **different tool sets to different callers** from one instrumented
instance is handled correctly: nothing is carried over from whichever listing
happened to be served last.

## Stateless / multi-pod servers

A stateless MCP server issues no session id, so `$session_id` fragments across pods
Expand Down
11 changes: 9 additions & 2 deletions posthog/mcp/_conversation_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,27 @@ def resolve_conversation_id(
enabled: bool,
args: Any,
tool_name: Optional[str],
missing_capability_tool_name: str,
missing_capability_tool_name: Optional[str],
feedback_tool_name: Optional[str] = None,
) -> Tuple[Optional[str], bool]:
"""Return ``(conversation_id, minted)``. Disabled, get_more_tools, or
send_feedback → ``(None, False)``; agent echoed a handle we could have minted
→ ``(value, False)``; anything else (omitted, or a value the agent made up)
→ ``(new uuid, True)``.

Either virtual tool's name arrives as ``None`` when that tool is disabled,
so a real application tool by the same name mints and echoes a handle like
any other tool's.

Lowercased on the way in: the shape test is case-insensitive but the hash
behind ``$session_id`` is not, so an uppercased echo (some hosts normalise
uuids) would land in a different session than the call that minted it."""
if (
not enabled
or tool_name == missing_capability_tool_name
or (
missing_capability_tool_name is not None
and tool_name == missing_capability_tool_name
)
or (feedback_tool_name is not None and tool_name == feedback_tool_name)
):
return None, False
Expand Down
61 changes: 31 additions & 30 deletions posthog/mcp/_instrument_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,13 @@
from ._instrument_lowlevel import _wrap_resource_requests
from ._instrumentation import (
_to_jsonable,
append_get_more_tools,
append_send_feedback,
apply_virtual_tool_injection,
collect_listed_tools,
extract_tools,
listing_has_next_page,
is_first_listing_page,
mutate_tool_schema,
refresh_feedback_shadow,
request_to_dict,
resolve_virtual_tool_injection,
resolve_session_and_client,
start_tool_call_lifecycle,
start_tools_list_lifecycle,
Expand All @@ -50,7 +49,7 @@
)
from ._output_instructions import mirror_instructions_into_structured_content
from .logger import log
from .tools import get_more_tools_result_text, resolve_missing_capability_tool_name
from .tools import get_more_tools_result_text

_WRAPPED_FLAG = "__posthog_mcp_wrapped__"

Expand Down Expand Up @@ -115,15 +114,15 @@ async def wrapped(
},
)

if lifecycle.is_missing_capability:
if lifecycle.is_missing_capability and (
_name_owned_by_real_tool(server, name) is False
):
await lifecycle.record_missing_capability()
return [
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
]

if lifecycle.is_feedback and not _feedback_name_owned_by_real_tool(
server, name
):
if lifecycle.is_feedback and (_name_owned_by_real_tool(server, name) is False):
reply = await lifecycle.record_feedback()
return [mcp_types.TextContent(type="text", text=reply)]

Expand Down Expand Up @@ -206,6 +205,7 @@ def _inject_tool_schemas(server: Any, data: MCPAnalyticsData, tools: list) -> No
schema_attribute="inputSchema",
owns_context=_tool_owns_context(server, tool.name),
context_required=True,
is_sdk_virtual_tool=False,
)


Expand All @@ -229,10 +229,6 @@ async def list_handler(req: Any) -> Any:
if req is None:
result = await original(req)
tools = extract_tools(result)
# Refresh the collision flag here too: this pass sees the real tool
# registry, so a real tool named like the feedback tool is detected
# before any client-facing listing.
refresh_feedback_shadow(data, tools)
_inject_tool_schemas(server, data, tools)
return result

Expand Down Expand Up @@ -272,19 +268,17 @@ async def list_handler(req: Any) -> Any:
tools = extract_tools(result)
# Empty is computed before adding the virtual missing-capability tool.
names, empty = collect_listed_tools(data, tools)
feedback_name = refresh_feedback_shadow(data, tools)
injection = resolve_virtual_tool_injection(
data,
tools,
is_first_page=is_first_listing_page(getattr(req, "params", None)),
)

_inject_tool_schemas(server, data, tools)

if data.options.report_missing:
missing_name = resolve_missing_capability_tool_name(data.options)
if not any(t.name == missing_name for t in tools):
append_get_more_tools(result, missing_name, data)
names.append(missing_name)

if feedback_name is not None and not listing_has_next_page(result):
append_send_feedback(result, data)
names.append(feedback_name)
result = apply_virtual_tool_injection(
result, injection, names, data, schema_field="inputSchema"
)

await lifecycle.record_result(
names=names,
Expand Down Expand Up @@ -322,14 +316,21 @@ def _inject_prompt_back(result: Any, conversation_id: str) -> Any:
return result


def _feedback_name_owned_by_real_tool(server: Any, name: str) -> bool:
"""Live registry probe so a real tool by the feedback tool's name is never
shadowed even before the first listing refreshes the collision flag."""
try:
tool_manager = getattr(server, "_tool_manager", None)
return tool_manager is not None and tool_manager.get_tool(name) is not None
except Exception: # noqa: BLE001 - unknown tool -> the name is not owned
def _name_owned_by_real_tool(server: Any, name: str) -> Optional[bool]:
"""Live registry probe, so a real tool by a virtual tool's name is never
shadowed. Tri-state like its low-level twin: ``None`` when the lookup failed
rather than answered, and callers must not intercept on it."""
tool_manager = getattr(server, "_tool_manager", None)
if tool_manager is None:
return False
try:
return tool_manager.get_tool(name) is not None
except Exception as err: # noqa: BLE001 - analytics must not break the call
log(
f'Warning: could not determine whether "{name}" is a real tool of '
f"yours; delegating the call to your server - {err}"
)
return None


def _tool_owns_param(server: Any, name: str, param: str) -> bool:
Expand Down
Loading