Skip to content

Commit 49ccde6

Browse files
authored
fix(mcp): preserve conversation result isolation
Generated-By: PostHog Desktop Task-Id: 6fa508f7-af04-48ef-b7ea-31732d3ca42e
1 parent 6576922 commit 49ccde6

5 files changed

Lines changed: 88 additions & 25 deletions

File tree

‎.sampo/changesets/mcp-dispatcher-conversations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
pypi/posthog: minor
33
---
44

5-
Add conversation and session correlation to custom `PostHogMCP` dispatchers, matching `@posthog/mcp`. `prepare_tool_list()` adds an optional `conversation_id` argument and a compatible `_mcp_instructions` output field, `prepare_tool_call()` accepts a carried `session_id` and returns the resolved `session_id` and `conversation_id`, and the new `prepare_tool_result()` delivers a minted handle without changing the original result. The capture methods accept `conversation_id`. Set `PostHogMCP(enable_conversation_id=False)` to keep the previous behavior.
5+
Add conversation and session correlation to custom `PostHogMCP` dispatchers, matching `@posthog/mcp`. `prepare_tool_list()` adds an optional `conversation_id` field to each compatible tool input schema and a compatible `_mcp_instructions` output field, `prepare_tool_call()` accepts a carried `session_id` and returns the resolved `session_id` and `conversation_id`, and the new `prepare_tool_result()` delivers a minted handle without changing the original result. The capture methods accept `conversation_id`. Set `PostHogMCP(enable_conversation_id=False)` to keep the previous behavior.

‎posthog/mcp/_output_instructions.py‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,35 @@ def mirror_instructions_into_structured_content(
218218
new_target = copy_model(update={attr: updated})
219219
except Exception: # noqa: BLE001 - never let delivery break the tool path
220220
return result, False
221-
if target is result:
222-
return new_target, True
223-
rewrap = getattr(result, "model_copy", None)
224-
if callable(rewrap):
225-
try:
226-
return rewrap(update={"root": new_target}), True
227-
except Exception: # noqa: BLE001
228-
return result, False
221+
else:
222+
new_target = _copy_with_attr(target, attr, updated)
223+
if new_target is None:
224+
return result, False
225+
if target is result:
226+
return new_target, True
227+
rewrap = getattr(result, "model_copy", None)
228+
if callable(rewrap):
229+
try:
230+
return rewrap(update={"root": new_target}), True
231+
except Exception: # noqa: BLE001
232+
return result, False
233+
new_result = _copy_with_attr(result, "root", new_target)
234+
if new_result is None:
229235
return result, False
236+
return new_result, True
237+
238+
239+
def _copy_with_attr(value: Any, attr: str, updated: Any) -> Optional[Any]:
240+
"""Return a shallow copy with one changed attribute, or ``None`` when the
241+
object cannot be copied safely."""
230242
try:
231-
setattr(target, attr, updated)
232-
except Exception: # noqa: BLE001 - never let delivery break the tool path
233-
return result, False
234-
return result, True
243+
copied = copy.copy(value)
244+
except Exception: # noqa: BLE001 - analytics must not break tool results
245+
return None
246+
if copied is value:
247+
return None
248+
try:
249+
setattr(copied, attr, updated)
250+
except Exception: # noqa: BLE001 - read-only result objects fail closed
251+
return None
252+
return copied

‎posthog/mcp/posthog_mcp.py‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -918,22 +918,27 @@ def _collect_conversation_ownership(
918918

919919

920920
def _inject_prompt_back(result: Any, conversation_id: str) -> Any:
921-
"""Append the handle to a dict or ``CallToolResult`` result's ``content``,
922-
including a ``CallToolResult`` inside an MCP SDK 1.x ``ServerResult``.
923-
Returns ``result`` itself when there is no content list to append to."""
921+
"""Append the handle to a tuple, list, dict, or ``CallToolResult`` result's
922+
``content``, including a ``CallToolResult`` inside an MCP SDK 1.x
923+
``ServerResult``. Returns ``result`` itself when there is no content list to
924+
append to."""
924925
if isinstance(result, dict):
925926
return inject_prompt_back(result, conversation_id)
926-
target = getattr(result, "root", result)
927-
content = getattr(target, "content", None)
928-
copy_model = getattr(target, "model_copy", None)
929-
if not isinstance(content, list) or not callable(copy_model):
930-
return result
931927
# A model result means the MCP SDK is installed; it stays a peer dependency.
932928
import mcp.types as mcp_types # noqa: PLC0415
933929

934930
block = mcp_types.TextContent(
935931
type="text", text=build_prompt_back(conversation_id)["text"]
936932
)
933+
if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], list):
934+
return ([*result[0], block], result[1])
935+
if isinstance(result, list):
936+
return [*result, block]
937+
target = getattr(result, "root", result)
938+
content = getattr(target, "content", None)
939+
copy_model = getattr(target, "model_copy", None)
940+
if not isinstance(content, list) or not callable(copy_model):
941+
return result
937942
try:
938943
updated = copy_model(update={"content": [*content, block]})
939944
if target is result:

‎posthog/test/mcp/test_output_instructions.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,13 @@ def test_mirrors_into_a_model_result_both_attr_shapes():
118118
for attr in ("structured_content", "structuredContent"):
119119
result = SimpleNamespace(**{attr: {"total": 7}})
120120

121-
_, delivered = mirror_instructions_into_structured_content(result, "conv-2")
121+
prepared, delivered = mirror_instructions_into_structured_content(
122+
result, "conv-2"
123+
)
122124

123125
assert delivered is True
124-
assert getattr(result, attr)[MCP_INSTRUCTIONS_KEY] == {
126+
assert getattr(result, attr) == {"total": 7}
127+
assert getattr(prepared, attr)[MCP_INSTRUCTIONS_KEY] == {
125128
"conversation_id": "conv-2"
126129
}
127130

@@ -130,10 +133,11 @@ def test_mirrors_through_a_serverresult_wrapper():
130133
inner = SimpleNamespace(structuredContent={"total": 1})
131134
result = SimpleNamespace(root=inner)
132135

133-
_, delivered = mirror_instructions_into_structured_content(result, "conv-3")
136+
prepared, delivered = mirror_instructions_into_structured_content(result, "conv-3")
134137

135138
assert delivered is True
136-
assert inner.structuredContent[MCP_INSTRUCTIONS_KEY] == {
139+
assert inner.structuredContent == {"total": 1}
140+
assert prepared.root.structuredContent[MCP_INSTRUCTIONS_KEY] == {
137141
"conversation_id": "conv-3"
138142
}
139143

‎posthog/test/mcp/test_posthog_mcp.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,42 @@ def test_prepare_tool_result_delivers_into_call_tool_result_models(wrapped):
530530
assert prepared.conversation_id == call.conversation_id
531531

532532

533+
def test_prepare_tool_result_delivers_minted_handle_into_tuple_content():
534+
client, _ = make_client()
535+
client.prepare_tool_list([_sql_tool()])
536+
call = client.prepare_tool_call("execute-sql", {})
537+
content = [TextContent(type="text", text="done")]
538+
tool_result = (content, None)
539+
540+
prepared = client.prepare_tool_result(tool_result, call)
541+
542+
assert len(content) == 1
543+
assert prepared.result[0][-1].text == _handle_block(call.conversation_id)["text"]
544+
assert prepared.conversation_id == call.conversation_id
545+
546+
547+
def test_prepare_tool_result_copies_shared_custom_result_objects():
548+
client, _ = make_client()
549+
client.prepare_tool_list([_sql_tool()])
550+
shared_result = SimpleNamespace(content=[], structuredContent={"rows": []})
551+
552+
first_call = client.prepare_tool_call("execute-sql", {})
553+
first = client.prepare_tool_result(shared_result, first_call)
554+
second_call = client.prepare_tool_call("execute-sql", {})
555+
second = client.prepare_tool_result(shared_result, second_call)
556+
557+
assert shared_result.structuredContent == {"rows": []}
558+
assert first.result is not shared_result
559+
assert second.result is not shared_result
560+
assert first.result.structuredContent[MCP_INSTRUCTIONS_KEY] == {
561+
"conversation_id": first_call.conversation_id
562+
}
563+
assert second.result.structuredContent[MCP_INSTRUCTIONS_KEY] == {
564+
"conversation_id": second_call.conversation_id
565+
}
566+
assert first_call.conversation_id != second_call.conversation_id
567+
568+
533569
def test_prepare_tool_result_omits_conversation_without_delivery_state():
534570
client, _ = make_client()
535571
tool_result = {"content": []}

0 commit comments

Comments
 (0)