Skip to content

Commit 8daec48

Browse files
committed
fix(mcp): decide tool ownership per request, not from the last listing
Capture intent for tools the host owns; skip it only for PostHog's own. The code asked that question two different ways and they disagreed. Interception asked a per-request probe, which was right. Intent and the conversation exemption asked `virtual_tool_collisions`, which only a served tools/list writes. On a process that had served none -- the ordinary multi-pod case the probe exists for -- a host tool named `get_more_tools` dispatched correctly but its $mcp_tool_call carried `$mcp_intent=None`. Permanent on FastMCP and v2 MCPServer; on raw low-level it self-healed after one call, because the MCP SDK's own `req is None` cache pass happens to refresh the state during dispatch. The intent guard turns out to be dead code for its stated purpose: PostHog's virtual tools are intercepted before dispatch and captured by record_missing_capability / record_feedback, so they never reach record_tool_call. Verified by spying on it. Everything that gets there is host-dispatched by construction, so the guard could only ever fire on a host tool sharing the name. Removed. The same applies to the conversation exemption: the virtual tools' capture paths discard the resolved handle, so exempting by name only ever cost the host's tool its handle. start_tool_call_lifecycle now reads `enabled_virtual_tool_names` instead of `injectable_`, so the call path stops reading listing state altogether. That also closes the cross-client report: the collision set is per-server and rewritten by whichever listing ran last, so reading it on the call path let one caller's catalogue decide another caller's call. Note on the reports: the missing conversation id they also cite is not name-specific -- an ordinary tool shows the same in that harness, so it is not a regression here. The new tests assert parity with an ordinary tool instead, which is the rule being fixed. The changeset claimed a real `get_more_tools` "keeps its $mcp_intent", which was only true after a listing on the same instance. Corrected. Reported by QA Swarm and veria-ai on #962. Generated-By: PostHog Desktop Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
1 parent fd42c1e commit 8daec48

6 files changed

Lines changed: 102 additions & 68 deletions

File tree

‎.sampo/changesets/mcp-virtual-tool-first-page.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ pypi/posthog: patch
55
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`.
66

77
- A real tool of yours named `get_more_tools` is no longer silently swallowed. It wins, and the warning names `missing_capability_tool_name`.
8-
- Custom tool names are honoured consistently: a renamed `get_more_tools` no longer gets a stray `conversation_id` argument, and a real `get_more_tools` of yours keeps its `context` injection and `$mcp_intent`.
8+
- Custom tool names are honoured consistently: a renamed `get_more_tools` no longer gets a stray `conversation_id` argument.
9+
- A real `get_more_tools` of yours is attributed like any other tool of yours, including its `$mcp_intent`. Attribution used to be decided from the last served `tools/list`, so on a process that had not served one — the ordinary multi-pod case — the intent was silently dropped.
910
- A server that returns the same `tools/list` result object on every request no longer reads PostHog's own injected tool back as a name collision.
1011
- Collision warnings now reach the `posthog.mcp` logger too, so they are visible without setting the `logger` option.
1112

‎posthog/mcp/_conversation_id.py‎

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,30 +77,27 @@ def extract_conversation_id(args: Any) -> Optional[str]:
7777
def resolve_conversation_id(
7878
enabled: bool,
7979
args: Any,
80-
tool_name: Optional[str],
81-
missing_capability_tool_name: Optional[str],
80+
tool_name: Optional[str] = None,
81+
missing_capability_tool_name: Optional[str] = None,
8282
feedback_tool_name: Optional[str] = None,
8383
) -> Tuple[Optional[str], bool]:
84-
"""Return ``(conversation_id, minted)``. Disabled, get_more_tools, or
85-
send_feedback → ``(None, False)``; agent echoed a handle we could have minted
86-
→ ``(value, False)``; anything else (omitted, or a value the agent made up)
87-
→ ``(new uuid, True)``.
84+
"""Return ``(conversation_id, minted)``. Disabled → ``(None, False)``; agent
85+
echoed a handle we could have minted → ``(value, False)``; anything else
86+
(omitted, or a value the agent made up) → ``(new uuid, True)``.
8887
89-
Either virtual tool's name arrives as ``None`` when it is disabled or when a
90-
real application tool owns it. A shadowed name belongs to that real tool, so
91-
it mints and echoes a handle like any other tool's.
88+
No tool is exempt by name. A name-based exemption cannot tell PostHog's
89+
virtual tool from a *host* tool that shares the name, and got it wrong in the
90+
direction that matters: the host's tool lost its handle. PostHog's own
91+
virtual tools discard the resolved handle on their own capture paths, so
92+
resolving one for them costs nothing.
93+
94+
The three tool-name parameters are accepted and ignored for backwards
95+
compatibility with callers that still pass them positionally.
9296
9397
Lowercased on the way in: the shape test is case-insensitive but the hash
9498
behind ``$session_id`` is not, so an uppercased echo (some hosts normalise
9599
uuids) would land in a different session than the call that minted it."""
96-
if (
97-
not enabled
98-
or (
99-
missing_capability_tool_name is not None
100-
and tool_name == missing_capability_tool_name
101-
)
102-
or (feedback_tool_name is not None and tool_name == feedback_tool_name)
103-
):
100+
if not enabled:
104101
return None, False
105102
supplied = extract_conversation_id(args)
106103
if supplied and _MINTED_CONVERSATION_ID.match(supplied):

‎posthog/mcp/_instrumentation.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -564,13 +564,14 @@ def start_tool_call_lifecycle(
564564
extra: Dict[str, Any],
565565
) -> ToolCallLifecycle:
566566
"""Resolve adapter-independent policy for a tool call without dispatching it."""
567-
# A name a real application tool is known to own resolves to None here, so
568-
# every downstream decision treats calls to it like any other tool's:
569-
# interception is skipped, and conversation-id resolution stops exempting it
570-
# as if it were the (shadowed) virtual tool.
571-
injectable = injectable_virtual_tool_names(data)
572-
missing_name = injectable.get(VIRTUAL_TOOL_MISSING_CAPABILITY)
573-
feedback_name = injectable.get(VIRTUAL_TOOL_FEEDBACK)
567+
# Options only -- deliberately NOT the listing-derived collision state.
568+
# That state is per-server and rewritten by whichever listing ran last, so a
569+
# server with caller-specific catalogues would answer one caller from
570+
# another's listing. Ownership on this path is settled per request by the
571+
# adapters' probes, which run right after this and are never stale.
572+
enabled = enabled_virtual_tool_names(data)
573+
missing_name = enabled.get(VIRTUAL_TOOL_MISSING_CAPABILITY)
574+
feedback_name = enabled.get(VIRTUAL_TOOL_FEEDBACK)
574575
# Still needed whatever the name resolution says: parsing the report and
575576
# running the host's `on_feedback` handler read the configured options.
576577
feedback_options = resolve_collect_feedback_options(data.options.collect_feedback)

‎posthog/mcp/_intent.py‎

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,16 @@ async def resolve_tool_call_intent(
5656
request: Dict[str, Any],
5757
extra: Optional[Dict[str, Any]] = None,
5858
) -> Optional[ResolvedIntent]:
59-
from ._instrumentation import (
60-
VIRTUAL_TOOL_MISSING_CAPABILITY,
61-
injectable_virtual_tool_names,
62-
)
63-
59+
# Only tools the host actually dispatched get here. PostHog's own virtual
60+
# tools are intercepted before dispatch and captured by
61+
# `record_missing_capability` / `record_feedback`, which set the event's
62+
# intent from their own arguments -- they never reach `record_tool_call`.
63+
#
64+
# So there is no virtual tool to exempt here, and a name-based exemption
65+
# could only ever fire on a *host* tool that happens to share the name,
66+
# silently dropping its `$mcp_intent`.
6467
context_argument = _get_context_argument(request)
65-
name = (request.get("params") or {}).get("name")
66-
# The virtual tool carries its intent in its own `context` argument, which
67-
# is captured as the event's own field rather than as `$mcp_intent`. Resolved
68-
# through the injectable map, so a *real* application tool that owns the name
69-
# keeps its `context` captured as intent like any other tool's.
70-
missing_name = injectable_virtual_tool_names(data).get(
71-
VIRTUAL_TOOL_MISSING_CAPABILITY
72-
)
73-
if (
74-
is_context_enabled(data.options.context)
75-
and (missing_name is None or name != missing_name)
76-
and context_argument
77-
):
68+
if is_context_enabled(data.options.context) and context_argument:
7869
return (context_argument, "context_parameter")
7970
return await _run_intent_fallback(data, request, extra)
8071

‎posthog/test/mcp/test_units.py‎

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
schema/loop-back, session-id rollover, and the identity cache. These complement the
33
end-to-end adapter tests by exercising edge branches directly."""
44

5+
import pytest
6+
57
from datetime import datetime, timedelta, timezone
68
from types import SimpleNamespace
79

@@ -58,29 +60,27 @@ async def test_intent_from_context_argument():
5860
assert out == ("do the thing", "context_parameter")
5961

6062

61-
async def test_intent_skips_context_for_missing_capability_tool():
62-
# a get_more_tools call's context is a capability report, not a tool-call
63-
# intent — but only while the SDK actually owns that name
63+
@pytest.mark.parametrize("report_missing", [False, True])
64+
async def test_intent_captures_context_whatever_the_tool_is_called(report_missing):
65+
# Only host-dispatched tools reach intent resolution: PostHog's own virtual
66+
# tools are intercepted first and set their event's intent from their own
67+
# arguments. So a call named `get_more_tools` arriving here is the *host's*
68+
# tool, and its context is an ordinary intent.
69+
#
70+
# A name-based exemption used to live here and could only fire on that host
71+
# tool, silently dropping its `$mcp_intent` -- and only on an instance that
72+
# had not yet served a tools/list, which made it look intermittent.
6473
out = await resolve_tool_call_intent(
65-
_data(report_missing=True),
74+
_data(report_missing=report_missing),
6675
_call(name="get_more_tools", args={"context": "need csv export"}),
6776
)
68-
assert out is None
69-
70-
71-
async def test_intent_keeps_context_for_a_real_tool_named_get_more_tools():
72-
# With report_missing off the SDK advertises no such tool, so one by that
73-
# name is the host's own and its context is an ordinary intent. Interception
74-
# has always been gated on report_missing, so dropping the intent here left
75-
# a real tool dispatched but unattributed.
76-
out = await resolve_tool_call_intent(
77-
_data(), _call(name="get_more_tools", args={"context": "need csv export"})
78-
)
7977
assert out == ("need csv export", "context_parameter")
8078

8179

82-
async def test_intent_keeps_context_when_a_real_tool_owns_the_name():
83-
# Same, via the listing-derived collision state rather than the switch.
80+
async def test_intent_is_independent_of_listing_derived_collision_state():
81+
# Per-request by construction: nothing here reads state a tools/list wrote,
82+
# so a server with caller-specific catalogues cannot attribute one caller's
83+
# call from another caller's listing.
8484
data = _data(report_missing=True)
8585
data.virtual_tool_collisions.add(VIRTUAL_TOOL_MISSING_CAPABILITY)
8686
out = await resolve_tool_call_intent(
@@ -311,11 +311,13 @@ def test_resolve_conversation_id_disabled():
311311
assert resolve_conversation_id(False, {}, "t", "get_more_tools") == (None, False)
312312

313313

314-
def test_resolve_conversation_id_skips_missing_capability_tool():
315-
assert resolve_conversation_id(True, {}, "get_more_tools", "get_more_tools") == (
316-
None,
317-
False,
318-
)
314+
def test_resolve_conversation_id_exempts_no_tool_by_name():
315+
# A name-based exemption cannot tell PostHog's virtual tool from a host tool
316+
# sharing the name, and got it wrong in the direction that matters: the
317+
# host's tool lost its handle. PostHog's virtual tools discard the resolved
318+
# handle on their own capture paths, so resolving one for them costs nothing.
319+
cid, minted = resolve_conversation_id(True, {}, "get_more_tools", "get_more_tools")
320+
assert minted is True and cid
319321

320322

321323
def test_resolve_conversation_id_mints_for_a_shadowed_virtual_tool_name():

‎posthog/test/mcp/test_virtual_tools.py‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,28 +201,45 @@ async def test_collision_un_shadows_once_the_real_tool_is_dropped():
201201

202202
async def test_registry_probe_blocks_interception_before_any_listing():
203203
# The multi-pod case: a call reaching a process that never served a
204-
# tools/list has empty collision state, so the registry is the only signal.
204+
# tools/list has no listing-derived state, so the registry is the only
205+
# signal. The host's tool must run AND be attributed exactly like any other
206+
# tool of theirs -- attribution used to be decided from listing state
207+
# instead, so $mcp_intent silently went missing on these instances only.
205208
server = FastMCP("virtual-tools-fastmcp")
206209

207210
@server.tool()
208211
def get_more_tools(context: str) -> str:
209212
return "real tool ran"
210213

214+
@server.tool()
215+
def ordinary(context: str) -> str:
216+
return "ordinary ran"
217+
211218
client = FakeClient()
212219
instrument(server, client, MCPAnalyticsOptions(report_missing=True))
213220

214221
out = await server._tool_manager.call_tool(
215222
"get_more_tools", {"context": "need csv export"}
216223
)
224+
await server._tool_manager.call_tool("ordinary", {"context": "need csv export"})
217225
await _flush()
218226

219227
assert "real tool ran" in str(out)
220228
assert _events(client, "$mcp_missing_capability") == []
221229

230+
colliding, sibling = _events(client, "$mcp_tool_call")
231+
assert colliding["properties"]["$mcp_intent"] == "need csv export"
232+
# Same treatment as an ordinary tool, which is the whole rule: intent is
233+
# captured for tools the host owns, skipped only for PostHog's own.
234+
assert (
235+
colliding["properties"]["$mcp_intent_source"]
236+
== sibling["properties"]["$mcp_intent_source"]
237+
)
238+
222239

223240
async def test_raw_list_probe_blocks_interception_before_any_listing():
224-
# A raw low-level server has no tool registry, so ownership is settled by
225-
# asking the host's own tools/list handler.
241+
# Same, on a raw low-level server: no tool registry, so ownership is settled
242+
# by asking the host's own tools/list handler.
226243
server = _make_paged_lowlevel([[_REAL_GET_MORE_TOOLS]])
227244
client = FakeClient()
228245
instrument(server, client, MCPAnalyticsOptions(report_missing=True))
@@ -232,6 +249,31 @@ async def test_raw_list_probe_blocks_interception_before_any_listing():
232249

233250
assert out.root.content[0].text == "real tool ran"
234251
assert _events(client, "$mcp_missing_capability") == []
252+
call = _events(client, "$mcp_tool_call")[0]
253+
assert call["properties"]["$mcp_intent"] == "need csv export"
254+
255+
256+
async def test_another_clients_listing_cannot_suppress_interception():
257+
# The collision set is per-server and rewritten by whichever listing ran
258+
# last, so reading it on the call path would let one caller's catalogue
259+
# decide another caller's call. The call path reads the per-request probe
260+
# instead, so a stale collision cannot suppress PostHog's virtual tool.
261+
server = _make_paged_lowlevel([[_ECHO_TOOL]])
262+
client = FakeClient()
263+
instrument(server, client, MCPAnalyticsOptions(report_missing=True))
264+
265+
from posthog.mcp._instrumentation import VIRTUAL_TOOL_MISSING_CAPABILITY
266+
from posthog.mcp._internal import get_server_tracking_data
267+
268+
get_server_tracking_data(server).virtual_tool_collisions.add(
269+
VIRTUAL_TOOL_MISSING_CAPABILITY
270+
)
271+
272+
out = await _call(server, "get_more_tools", {"context": "need csv export"})
273+
await _flush()
274+
275+
assert out.root.content[0].text == get_more_tools_result_text()
276+
assert _events(client, "$mcp_missing_capability")
235277

236278

237279
async def test_both_virtual_tools_configured_with_the_same_name():

0 commit comments

Comments
 (0)