Skip to content

Commit 4cd42e9

Browse files
committed
fix(mcp): anchor virtual tools to conversations
Generated-By: PostHog Desktop Task-Id: e71933e1-34f5-4e2f-b1dc-da92ab68ff26
1 parent 46bdbe8 commit 4cd42e9

14 files changed

Lines changed: 194 additions & 112 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
MCP virtual tools now use conversation IDs when `enable_conversation_id` is enabled.

posthog/mcp/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ needs no middleware and no ordering discipline, and it is the only thing that
299299
correlates a session under the 2026-07-28 revision's per-request server instances.
300300
Prefer it if you're on a recent client.
301301

302+
The `get_more_tools` and `send_feedback` virtual tools also use the conversation
303+
handle when this option is enabled.
304+
302305
### How the SDK tells you it's misconfigured
303306

304307
The failure used to be silent. It now surfaces two ways:

posthog/mcp/_conversation_id.py

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -74,33 +74,13 @@ def extract_conversation_id(args: Any) -> Optional[str]:
7474
return trimmed or None
7575

7676

77-
def resolve_conversation_id(
78-
enabled: bool,
79-
args: Any,
80-
tool_name: Optional[str],
81-
missing_capability_tool_name: Optional[str],
82-
feedback_tool_name: Optional[str] = None,
83-
) -> 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)``.
88-
89-
Either virtual tool's name arrives as ``None`` when that tool is disabled,
90-
so a real application tool by the same name mints and echoes a handle like
91-
any other tool's.
77+
def resolve_conversation_id(enabled: bool, args: Any) -> Tuple[Optional[str], bool]:
78+
"""Return the conversation id and whether the SDK minted it.
9279
9380
Lowercased on the way in: the shape test is case-insensitive but the hash
9481
behind ``$session_id`` is not, so an uppercased echo (some hosts normalise
9582
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-
):
83+
if not enabled:
10484
return None, False
10585
supplied = extract_conversation_id(args)
10686
if supplied and _MINTED_CONVERSATION_ID.match(supplied):

posthog/mcp/_instrument_fastmcp.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,24 @@ async def wrapped(
118118
if lifecycle.is_missing_capability and (
119119
_name_owned_by_real_tool(server, name) is False
120120
):
121-
await lifecycle.record_missing_capability()
122-
return [
121+
virtual_content = [
123122
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
124123
]
124+
if prompt_back := lifecycle.prompt_back_text():
125+
virtual_content.append(
126+
mcp_types.TextContent(type="text", text=prompt_back)
127+
)
128+
await lifecycle.record_missing_capability(conversation_id_delivered=True)
129+
return virtual_content
125130

126131
if lifecycle.is_feedback and (_name_owned_by_real_tool(server, name) is False):
127-
reply = await lifecycle.record_feedback()
128-
return [mcp_types.TextContent(type="text", text=reply)]
132+
reply = await lifecycle.record_feedback(conversation_id_delivered=True)
133+
virtual_content = [mcp_types.TextContent(type="text", text=reply)]
134+
if prompt_back := lifecycle.prompt_back_text():
135+
virtual_content.append(
136+
mcp_types.TextContent(type="text", text=prompt_back)
137+
)
138+
return virtual_content
129139

130140
# Strip each injected key independently. A tool can declare its own
131141
# `context` (kept) while `conversation_id` is still SDK-injected (stripped),

posthog/mcp/_instrument_lowlevel.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,25 +212,33 @@ async def handler(req: Any) -> Any:
212212
if lifecycle.is_missing_capability and (
213213
await _name_owned_by_real_tool(high_level, data, name, server) is False
214214
):
215-
await lifecycle.record_missing_capability()
215+
virtual_content = [
216+
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
217+
]
218+
if prompt_back := lifecycle.prompt_back_text():
219+
virtual_content.append(
220+
mcp_types.TextContent(type="text", text=prompt_back)
221+
)
222+
await lifecycle.record_missing_capability(conversation_id_delivered=True)
216223
return mcp_types.ServerResult(
217224
mcp_types.CallToolResult(
218-
content=[
219-
mcp_types.TextContent(
220-
type="text", text=get_more_tools_result_text()
221-
)
222-
],
225+
content=virtual_content,
223226
isError=False,
224227
)
225228
)
226229

227230
if lifecycle.is_feedback and (
228231
await _name_owned_by_real_tool(high_level, data, name, server) is False
229232
):
230-
reply = await lifecycle.record_feedback()
233+
reply = await lifecycle.record_feedback(conversation_id_delivered=True)
234+
virtual_content = [mcp_types.TextContent(type="text", text=reply)]
235+
if prompt_back := lifecycle.prompt_back_text():
236+
virtual_content.append(
237+
mcp_types.TextContent(type="text", text=prompt_back)
238+
)
231239
return mcp_types.ServerResult(
232240
mcp_types.CallToolResult(
233-
content=[mcp_types.TextContent(type="text", text=reply)],
241+
content=virtual_content,
234242
isError=False,
235243
)
236244
)

posthog/mcp/_instrument_v2.py

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -302,22 +302,26 @@ async def wrapped(
302302
if lifecycle.is_missing_capability and (
303303
_name_owned_by_real_tool_v2(server, name) is False
304304
):
305-
await lifecycle.record_missing_capability()
306-
return mcp_types.CallToolResult(
307-
content=[
308-
mcp_types.TextContent(
309-
type="text", text=get_more_tools_result_text()
310-
)
311-
]
312-
)
305+
virtual_content = [
306+
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
307+
]
308+
if prompt_back := lifecycle.prompt_back_text():
309+
virtual_content.append(
310+
mcp_types.TextContent(type="text", text=prompt_back)
311+
)
312+
await lifecycle.record_missing_capability(conversation_id_delivered=True)
313+
return mcp_types.CallToolResult(content=virtual_content)
313314

314315
if lifecycle.is_feedback and (
315316
_name_owned_by_real_tool_v2(server, name) is False
316317
):
317-
reply = await lifecycle.record_feedback()
318-
return mcp_types.CallToolResult(
319-
content=[mcp_types.TextContent(type="text", text=reply)]
320-
)
318+
reply = await lifecycle.record_feedback(conversation_id_delivered=True)
319+
virtual_content = [mcp_types.TextContent(type="text", text=reply)]
320+
if prompt_back := lifecycle.prompt_back_text():
321+
virtual_content.append(
322+
mcp_types.TextContent(type="text", text=prompt_back)
323+
)
324+
return mcp_types.CallToolResult(content=virtual_content)
321325

322326
# v2 validates against the function signature and rejects unexpected
323327
# keys, so injected parameters are stripped before dispatch — but never
@@ -526,22 +530,26 @@ async def handler(ctx: Any, params: Any) -> Any:
526530
if lifecycle.is_missing_capability and (
527531
await raw_listing_owns_tool_name(data, name, ctx) is False
528532
):
529-
await lifecycle.record_missing_capability()
530-
return mcp_types.CallToolResult(
531-
content=[
532-
mcp_types.TextContent(
533-
type="text", text=get_more_tools_result_text()
534-
)
535-
]
536-
)
533+
virtual_content = [
534+
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
535+
]
536+
if prompt_back := lifecycle.prompt_back_text():
537+
virtual_content.append(
538+
mcp_types.TextContent(type="text", text=prompt_back)
539+
)
540+
await lifecycle.record_missing_capability(conversation_id_delivered=True)
541+
return mcp_types.CallToolResult(content=virtual_content)
537542

538543
if lifecycle.is_feedback and (
539544
await raw_listing_owns_tool_name(data, name, ctx) is False
540545
):
541-
reply = await lifecycle.record_feedback()
542-
return mcp_types.CallToolResult(
543-
content=[mcp_types.TextContent(type="text", text=reply)]
544-
)
546+
reply = await lifecycle.record_feedback(conversation_id_delivered=True)
547+
virtual_content = [mcp_types.TextContent(type="text", text=reply)]
548+
if prompt_back := lifecycle.prompt_back_text():
549+
virtual_content.append(
550+
mcp_types.TextContent(type="text", text=prompt_back)
551+
)
552+
return mcp_types.CallToolResult(content=virtual_content)
545553

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

posthog/mcp/_instrumentation.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
is_context_enabled,
2424
schema_has_param,
2525
)
26-
from ._conversation_id import add_conversation_id_to_schema, resolve_conversation_id
26+
from ._conversation_id import (
27+
add_conversation_id_to_schema,
28+
build_prompt_back,
29+
resolve_conversation_id,
30+
)
2731
from ._event_types import MCPAnalyticsEventType
2832
from ._exceptions import capture_exception
2933
from .feedback import (
@@ -470,11 +474,25 @@ async def prime_session(self) -> None:
470474
self.data, mcp_session_id=self.mcp_session_id, token=self.token
471475
)
472476

473-
async def record_missing_capability(self) -> None:
474-
session_id = await self.prepare_session(None)
477+
def prompt_back_text(self) -> Optional[str]:
478+
if not self.conversation_id or not self.minted_conversation_id:
479+
return None
480+
return build_prompt_back(self.conversation_id)["text"]
481+
482+
def _anchored_conversation_id(self, delivered: bool) -> Optional[str]:
483+
if self.minted_conversation_id and not delivered:
484+
return None
485+
return self.conversation_id
486+
487+
async def record_missing_capability(
488+
self, *, conversation_id_delivered: bool = False
489+
) -> None:
490+
conversation_id = self._anchored_conversation_id(conversation_id_delivered)
491+
session_id = await self.prepare_session(conversation_id)
475492
await record_missing_capability(
476493
self.data,
477494
session_id,
495+
conversation_id=conversation_id,
478496
tool_name=self.missing_name or self.name,
479497
context=(self.arguments or {}).get("context"),
480498
arguments=self.arguments,
@@ -486,15 +504,17 @@ async def record_missing_capability(self) -> None:
486504
extra=self.extra,
487505
)
488506

489-
async def record_feedback(self) -> str:
507+
async def record_feedback(self, *, conversation_id_delivered: bool = False) -> str:
490508
"""Capture the ``$mcp_feedback`` event, then run the host's ``on_feedback``
491509
handler and return the reply text for the agent. The event is captured
492510
whether or not the handler raises."""
493511
report = parse_feedback_report(self.arguments, self.feedback_options)
494-
session_id = await self.prepare_session(None)
512+
conversation_id = self._anchored_conversation_id(conversation_id_delivered)
513+
session_id = await self.prepare_session(conversation_id)
495514
await record_feedback(
496515
self.data,
497516
session_id,
517+
conversation_id=conversation_id,
498518
report=report,
499519
tool_name=self.feedback_name or self.name,
500520
arguments=self.arguments,
@@ -509,7 +529,7 @@ async def record_feedback(self) -> str:
509529
async def record_error(self, error: Any, duration_ms: float) -> None:
510530
# A freshly minted handle cannot anchor or be captured when dispatch
511531
# raised: no adapter had an opportunity to deliver it to the agent.
512-
conversation_id = None if self.minted_conversation_id else self.conversation_id
532+
conversation_id = self._anchored_conversation_id(False)
513533
session_id = await self.prepare_session(conversation_id)
514534
await record_tool_call(
515535
self.data,
@@ -530,9 +550,7 @@ async def record_error(self, error: Any, duration_ms: float) -> None:
530550
async def record_result(
531551
self, result: Any, duration_ms: float, *, conversation_id_delivered: bool
532552
) -> None:
533-
conversation_id = self.conversation_id
534-
if self.minted_conversation_id and not conversation_id_delivered:
535-
conversation_id = None
553+
conversation_id = self._anchored_conversation_id(conversation_id_delivered)
536554
session_id = await self.prepare_session(conversation_id)
537555
await record_tool_call(
538556
self.data,
@@ -573,11 +591,7 @@ def start_tool_call_lifecycle(
573591
# running the host's `on_feedback` handler read the configured options.
574592
feedback_options = resolve_collect_feedback_options(data.options.collect_feedback)
575593
conversation_id, minted = resolve_conversation_id(
576-
data.options.enable_conversation_id,
577-
arguments,
578-
name,
579-
missing_name,
580-
feedback_name,
594+
data.options.enable_conversation_id, arguments
581595
)
582596
return ToolCallLifecycle(
583597
data=data,
@@ -1010,10 +1024,8 @@ def mutate_tool_schema(
10101024
data.tool_model_parameter_injected[tool.name] = (
10111025
not app_owns_model and schema_has_param(schema, "llm_model")
10121026
)
1013-
if (
1014-
not is_sdk_virtual_tool
1015-
and data.options.enable_conversation_id
1016-
and not schema_has_param(schema, "conversation_id")
1027+
if data.options.enable_conversation_id and not schema_has_param(
1028+
schema, "conversation_id"
10171029
):
10181030
schema = add_conversation_id_to_schema(schema, tool.name)
10191031
if schema is not original_schema:
@@ -1138,6 +1150,7 @@ async def record_missing_capability(
11381150
data: MCPAnalyticsData,
11391151
session_id: str,
11401152
*,
1153+
conversation_id: Optional[str] = None,
11411154
tool_name: str,
11421155
context: Optional[str],
11431156
arguments: Optional[Dict[str, Any]],
@@ -1155,6 +1168,7 @@ async def record_missing_capability(
11551168
event: Dict[str, Any] = {
11561169
"event_type": MCPAnalyticsEventType.MCP_MISSING_CAPABILITY,
11571170
"session_id": session_id,
1171+
"conversation_id": conversation_id,
11581172
"resource_name": tool_name,
11591173
"parameters": build_captured_mcp_parameters(
11601174
request, strip_llm_model=allow_self_reported_model
@@ -1186,6 +1200,7 @@ async def record_feedback(
11861200
data: MCPAnalyticsData,
11871201
session_id: str,
11881202
*,
1203+
conversation_id: Optional[str] = None,
11891204
report: FeedbackReport,
11901205
tool_name: str,
11911206
arguments: Optional[Dict[str, Any]],
@@ -1206,6 +1221,7 @@ async def record_feedback(
12061221
event: Dict[str, Any] = {
12071222
"event_type": MCPAnalyticsEventType.MCP_FEEDBACK,
12081223
"session_id": session_id,
1224+
"conversation_id": conversation_id,
12091225
"resource_name": tool_name,
12101226
"client_name": client_name,
12111227
"client_version": client_version,

posthog/test/mcp/test_conversation_session.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def test_derivation_is_deterministic_and_distinct():
6262

6363
def test_echo_of_a_mintable_handle_is_accepted():
6464
cid, minted = resolve_conversation_id(
65-
True, {"conversation_id": MINTED_SHAPE_HANDLE}, "t", "get_more_tools"
65+
True, {"conversation_id": MINTED_SHAPE_HANDLE}
6666
)
6767
assert minted is False
6868
assert cid == MINTED_SHAPE_HANDLE
@@ -73,7 +73,7 @@ def test_uppercased_echo_is_lowercased_before_hashing():
7373
# case-sensitive, so the echo must be folded back or it lands in a
7474
# different session than the call that minted it.
7575
cid, minted = resolve_conversation_id(
76-
True, {"conversation_id": MINTED_SHAPE_HANDLE.upper()}, "t", "get_more_tools"
76+
True, {"conversation_id": MINTED_SHAPE_HANDLE.upper()}
7777
)
7878
assert minted is False
7979
assert cid == MINTED_SHAPE_HANDLE
@@ -82,9 +82,7 @@ def test_uppercased_echo_is_lowercased_before_hashing():
8282
def test_invented_handle_is_not_anchored():
8383
# Two unrelated users both sending "conv-1" must NOT share a session, so a
8484
# value we could not have minted is replaced with a fresh handle.
85-
cid, minted = resolve_conversation_id(
86-
True, {"conversation_id": "conv-1"}, "t", "get_more_tools"
87-
)
85+
cid, minted = resolve_conversation_id(True, {"conversation_id": "conv-1"})
8886
assert minted is True
8987
assert cid != "conv-1"
9088

posthog/test/mcp/test_fastmcp.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ async def test_list_tools_injects_model_into_real_and_virtual_tools():
8585
assert "llm_model" in tools[name].inputSchema["required"]
8686

8787

88+
async def test_virtual_tool_uses_conversation_id():
89+
server = make_server()
90+
client = FakeClient()
91+
instrument(
92+
server,
93+
client,
94+
MCPAnalyticsOptions(report_missing=True, enable_conversation_id=True),
95+
)
96+
97+
listed = await _list_tools(server)
98+
tool = next(t for t in listed.root.tools if t.name == "get_more_tools")
99+
assert "conversation_id" in tool.inputSchema["properties"]
100+
101+
result = await server._tool_manager.call_tool("get_more_tools", {"context": "csv"})
102+
await _flush()
103+
104+
handle = _events(client, "$mcp_missing_capability")[0]["properties"][
105+
"$mcp_conversation_id"
106+
]
107+
assert any(handle in item.text for item in result)
108+
109+
88110
# --- tools/call --------------------------------------------------------------
89111

90112

0 commit comments

Comments
 (0)