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

Anchor MCP analytics virtual tools to the conversation. With `enable_conversation_id=True`, `get_more_tools` and `send_feedback` now advertise `conversation_id`, echo a minted handle back, and stamp `$mcp_conversation_id` — whatever you rename them to.

They were exempt before, so a `$mcp_feedback` or `$mcp_missing_capability` event was filed under a `$session_id` of its own: an agent's complaint about a tool landed in a different session than the call it was complaining about, along with a spurious second `$mcp_initialize`. Reports now share the session they are about.

The virtual tools still never get the injected `context` argument — they state their intent through their own.

Ownership is now decided per request rather than from the last served `tools/list`, which also fixes attribution for a real tool of yours sharing a virtual tool's name: it is captured like any other tool of yours, `$mcp_intent` included. Previously that depended on the process having served a listing, so on a multi-pod deployment the intent was silently dropped. And a server that serves different tool sets to different callers can no longer have one caller's listing suppress another caller's virtual-tool handling.
8 changes: 8 additions & 0 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,14 @@ needs no middleware and no ordering discipline, and it is the only thing that
correlates a session under the 2026-07-28 revision's per-request server instances.
Prefer it if you're on a recent client.

The SDK's own virtual tools take part like any other tool, whatever you rename
them to: `get_more_tools` and `send_feedback` advertise `conversation_id`, echo a
minted handle back, and stamp `$mcp_conversation_id`. That is what keeps a report
in the session it is about — an agent's complaint about a tool should be reachable
from the calls that prompted it, not filed under a session of its own. They still
never get the injected `context` argument, since they state their intent through
their own.

### How the SDK tells you it's misconfigured

The failure used to be silent. It now surfaces two ways:
Expand Down
36 changes: 13 additions & 23 deletions posthog/mcp/_conversation_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,33 +74,23 @@ def extract_conversation_id(args: Any) -> Optional[str]:
return trimmed or None


def resolve_conversation_id(
enabled: bool,
args: Any,
tool_name: Optional[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 it is disabled or when a
real application tool owns it. A shadowed name belongs to that real tool, so
it mints and echoes a handle like any other tool's.
def resolve_conversation_id(enabled: bool, args: Any) -> Tuple[Optional[str], bool]:
"""Return ``(conversation_id, minted)``. Disabled → ``(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)``.

No tool is exempt by name, the SDK's own virtual tools included. A name-based
exemption was wrong in both directions: it could not tell PostHog's virtual
tool from a *host* tool sharing the name, so the host's tool lost its handle;
and for PostHog's own tools it filed a ``$mcp_feedback`` or
``$mcp_missing_capability`` event under a ``$session_id`` of its own, so an
agent's complaint about a tool landed nowhere near the call it was
complaining about. Anchoring the report to that call is the point of it.

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 (
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)
):
if not enabled:
return None, False
supplied = extract_conversation_id(args)
if supplied and _MINTED_CONVERSATION_ID.match(supplied):
Expand Down
36 changes: 30 additions & 6 deletions posthog/mcp/_instrument_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,16 @@ async def wrapped(
if lifecycle.is_missing_capability and not _name_owned_by_real_tool(
server, name
):
await lifecycle.record_missing_capability()
return [
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
]
prompt_back, delivered = _conversation_prompt_back(lifecycle)
await lifecycle.record_missing_capability(
conversation_id_delivered=delivered
)
return _with_prompt_back(get_more_tools_result_text(), prompt_back)

if lifecycle.is_feedback and not _name_owned_by_real_tool(server, name):
reply = await lifecycle.record_feedback()
return [mcp_types.TextContent(type="text", text=reply)]
prompt_back, delivered = _conversation_prompt_back(lifecycle)
reply = await lifecycle.record_feedback(conversation_id_delivered=delivered)
return _with_prompt_back(reply, prompt_back)

# Strip each injected key independently. A tool can declare its own
# `context` (kept) while `conversation_id` is still SDK-injected (stripped),
Expand Down Expand Up @@ -332,6 +334,28 @@ def _inject_prompt_back(result: Any, conversation_id: str) -> Any:
return result


def _conversation_prompt_back(lifecycle: Any) -> Tuple[Any, bool]:
"""``(prompt_back_block_or_None, delivered)`` for a virtual tool's reply.

A minted handle rides a prompt-back block so the agent can echo it on its
next call and keep the whole exchange in one session; an echoed one needs no
delivery. ``delivered`` gates stamping the handle on the event, so a handle
the agent never received is never recorded."""
if lifecycle.conversation_id and lifecycle.minted_conversation_id:
block = mcp_types.TextContent(
type="text", text=build_prompt_back(lifecycle.conversation_id)["text"]
)
return block, True
return None, bool(lifecycle.conversation_id)
Comment on lines +337 to +349

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated prompt-back policy

This helper repeats the same mint/delivery logic in _instrument_lowlevel.py and _instrument_v2.py, recreating the risk of adapters drifting apart. It violates the repository’s “says everything once and only once” requirement, which must be satisfied before merging. Move the shared policy into one helper and leave only adapter-specific result wrapping in each adapter.

Context Used: Be direct and concise: state the issue, its impact, and the fix, with no preamble or praise. Do not comment on alphabetical sorting, trailing commas, or formatting. Linters catch these. Judge code by four simplicity rules: it passes all the tests, ex... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/mcp/_instrument_fastmcp.py
Line: 337-349

Comment:
**Duplicated prompt-back policy**

This helper repeats the same mint/delivery logic in `_instrument_lowlevel.py` and `_instrument_v2.py`, recreating the risk of adapters drifting apart. It violates the repository’s “says everything once and only once” requirement, which must be satisfied before merging. Move the shared policy into one helper and leave only adapter-specific result wrapping in each adapter.

**Context Used:** Be direct and concise: state the issue, its impact, and the fix, with no preamble or praise. Do not comment on alphabetical sorting, trailing commas, or formatting. Linters catch these. Judge code by four simplicity rules: it passes all the tests, ex... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!



def _with_prompt_back(text: str, prompt_back: Any) -> list:
blocks = [mcp_types.TextContent(type="text", text=text)]
if prompt_back is not None:
blocks.append(prompt_back)
return blocks


def _name_owned_by_real_tool(server: Any, name: str) -> bool:
"""Live registry probe so a real tool by a virtual tool's name is never
shadowed, even before the first listing refreshes the collision state.
Expand Down
48 changes: 31 additions & 17 deletions posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,28 +211,18 @@ async def handler(req: Any) -> Any:
if lifecycle.is_missing_capability and (
await _name_owned_by_real_tool(high_level, data, name, server) is False
):
await lifecycle.record_missing_capability()
return mcp_types.ServerResult(
mcp_types.CallToolResult(
content=[
mcp_types.TextContent(
type="text", text=get_more_tools_result_text()
)
],
isError=False,
)
prompt_back, delivered = _conversation_prompt_back(lifecycle)
await lifecycle.record_missing_capability(
conversation_id_delivered=delivered
)
return _virtual_tool_result(get_more_tools_result_text(), prompt_back)

if lifecycle.is_feedback and (
await _name_owned_by_real_tool(high_level, data, name, server) is False
):
reply = await lifecycle.record_feedback()
return mcp_types.ServerResult(
mcp_types.CallToolResult(
content=[mcp_types.TextContent(type="text", text=reply)],
isError=False,
)
)
prompt_back, delivered = _conversation_prompt_back(lifecycle)
reply = await lifecycle.record_feedback(conversation_id_delivered=delivered)
return _virtual_tool_result(reply, prompt_back)

# On raw low-level servers `context`/`conversation_id` are injected as
# *optional* schema properties and left in place (a (name, arguments)
Expand Down Expand Up @@ -454,6 +444,30 @@ async def handler(req: Any) -> Any:
handlers[mcp_types.ListToolsRequest] = handler


def _conversation_prompt_back(lifecycle: Any) -> Tuple[Any, bool]:
"""``(prompt_back_block_or_None, delivered)`` for a virtual tool's reply.

A minted handle rides a prompt-back block so the agent can echo it on its
next call and keep the whole exchange in one session; an echoed one needs no
delivery. ``delivered`` gates stamping the handle on the event, so a handle
the agent never received is never recorded."""
if lifecycle.conversation_id and lifecycle.minted_conversation_id:
block = mcp_types.TextContent(
type="text", text=build_prompt_back(lifecycle.conversation_id)["text"]
)
return block, True
return None, bool(lifecycle.conversation_id)


def _virtual_tool_result(text: str, prompt_back: Any) -> Any:
content = [mcp_types.TextContent(type="text", text=text)]
if prompt_back is not None:
content.append(prompt_back)
return mcp_types.ServerResult(
mcp_types.CallToolResult(content=content, isError=False)
)


async def _name_owned_by_real_tool(
high_level: Any, data: MCPAnalyticsData, name: str, server: Any
) -> Optional[bool]:
Expand Down
58 changes: 36 additions & 22 deletions posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,20 +304,16 @@ async def wrapped(
if lifecycle.is_missing_capability and not _name_owned_by_real_tool_v2(
server, name
):
await lifecycle.record_missing_capability()
return mcp_types.CallToolResult(
content=[
mcp_types.TextContent(
type="text", text=get_more_tools_result_text()
)
]
prompt_back, delivered = _conversation_prompt_back_v2(lifecycle)
await lifecycle.record_missing_capability(
conversation_id_delivered=delivered
)
return _virtual_tool_result_v2(get_more_tools_result_text(), prompt_back)

if lifecycle.is_feedback and not _name_owned_by_real_tool_v2(server, name):
reply = await lifecycle.record_feedback()
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type="text", text=reply)]
)
prompt_back, delivered = _conversation_prompt_back_v2(lifecycle)
reply = await lifecycle.record_feedback(conversation_id_delivered=delivered)
return _virtual_tool_result_v2(reply, prompt_back)

# v2 validates against the function signature and rejects unexpected
# keys, so injected parameters are stripped before dispatch — but never
Expand Down Expand Up @@ -405,6 +401,28 @@ def _append_prompt_back(result: Any, conversation_id: str) -> Tuple[Any, bool]:
return result, True


def _conversation_prompt_back_v2(lifecycle: Any) -> Tuple[Any, bool]:
"""``(prompt_back_block_or_None, delivered)`` for a virtual tool's reply.

A minted handle rides a prompt-back block so the agent can echo it on its
next call and keep the whole exchange in one session; an echoed one needs no
delivery. ``delivered`` gates stamping the handle on the event, so a handle
the agent never received is never recorded."""
if lifecycle.conversation_id and lifecycle.minted_conversation_id:
block = mcp_types.TextContent(
type="text", text=build_prompt_back(lifecycle.conversation_id)["text"]
)
return block, True
return None, bool(lifecycle.conversation_id)


def _virtual_tool_result_v2(text: str, prompt_back: Any) -> Any:
content = [mcp_types.TextContent(type="text", text=text)]
if prompt_back is not None:
content.append(prompt_back)
return mcp_types.CallToolResult(content=content)


def _deliver_conversation_id(
data: MCPAnalyticsData, result: Any, name: str, conversation_id: str, minted: bool
) -> Tuple[Any, bool]:
Expand Down Expand Up @@ -526,22 +544,18 @@ async def handler(ctx: Any, params: Any) -> Any:
if lifecycle.is_missing_capability and (
await raw_listing_owns_tool_name(data, name, ctx) is False
):
await lifecycle.record_missing_capability()
return mcp_types.CallToolResult(
content=[
mcp_types.TextContent(
type="text", text=get_more_tools_result_text()
)
]
prompt_back, delivered = _conversation_prompt_back_v2(lifecycle)
await lifecycle.record_missing_capability(
conversation_id_delivered=delivered
)
return _virtual_tool_result_v2(get_more_tools_result_text(), prompt_back)

if lifecycle.is_feedback and (
await raw_listing_owns_tool_name(data, name, ctx) is False
):
reply = await lifecycle.record_feedback()
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(type="text", text=reply)]
)
prompt_back, delivered = _conversation_prompt_back_v2(lifecycle)
reply = await lifecycle.record_feedback(conversation_id_delivered=delivered)
return _virtual_tool_result_v2(reply, prompt_back)

# Settle the shared session before the tool body runs, so an in-tool
# `analytics.capture()` is attributed to this caller and not the last one.
Expand Down
Loading
Loading