Skip to content

Commit 3eb3861

Browse files
geshclaude
andauthored
feat(mcp): support conversations in custom dispatchers (#982)
* feat(mcp): support conversations in custom dispatchers Port posthog-js#5074 to PostHogMCP. Custom dispatchers now correlate calls through an agent-carried conversation_id and a derived session id, like instrument() already does. - enable_conversation_id constructor option, on by default - prepare_tool_list() injects conversation_id and _mcp_instructions - prepare_tool_call() accepts a carried session_id and returns the resolved session_id and conversation_id - new prepare_tool_result() delivers a minted handle without mutating the original result - capture methods accept conversation_id Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(mcp): preserve conversation result isolation Generated-By: PostHog Desktop Task-Id: 6fa508f7-af04-48ef-b7ea-31732d3ca42e * fix(mcp): keep custom results dependency-free Generated-By: PostHog Desktop Task-Id: 6fa508f7-af04-48ef-b7ea-31732d3ca42e * fix(mcp): preserve dispatcher conversation ownership Generated-By: PostHog Desktop Task-Id: 6fa508f7-af04-48ef-b7ea-31732d3ca42e --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5678f0b commit 3eb3861

12 files changed

Lines changed: 863 additions & 64 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
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`. The new `prepare_tool_result()` delivers a minted handle without changing the original result. Tool and report capture methods accept `conversation_id`. Existing dispatchers must call `prepare_tool_result()` to deliver new handles. Set `PostHogMCP(enable_conversation_id=False)` to keep the previous behavior.

‎posthog/mcp/README.md‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ strict-schema clients see the new field. Set `capture_model=False` to leave sche
7373
Conversation correlation adds an optional `conversation_id` argument and returns a handle in
7474
eligible tool results. Clients must echo it to group later calls; calls without it mint new handles.
7575
Set `enable_conversation_id=False` to retain transport-based session grouping and unchanged
76-
response content. Custom `PostHogMCP` dispatchers enable model capture by default but still
77-
supply their own session IDs. The reasoning is recorded in posthog-js `docs/adr/0013`.
76+
response content. Custom `PostHogMCP` dispatchers also enable model capture and conversation
77+
correlation by default. The reasoning is recorded in posthog-js `docs/adr/0013`.
7878

7979
## Capture the calling model
8080

@@ -119,8 +119,10 @@ authorization middleware with those hooks; argument-based model capture is skipp
119119
application-owned value cannot be mistaken for analytics. No additional catalog lookup runs
120120
during a tool call.
121121

122-
For a custom dispatcher, `PostHogMCP` enables the same option by default; pass request
123-
metadata through explicitly:
122+
For a custom dispatcher, `PostHogMCP` enables model capture and conversation correlation
123+
by default. `prepare_tool_list()` injects the analytics fields and records ownership by tool
124+
name. `prepare_tool_call()` removes SDK-owned arguments and resolves the conversation and
125+
session. `prepare_tool_result()` returns the result to send and the final values to capture:
124126

125127
```python
126128
from posthog.mcp import PostHogMCP
@@ -133,22 +135,34 @@ call = posthog.prepare_tool_call(
133135
raw_args,
134136
request_meta=request.get("params", {}).get("_meta"),
135137
original_tool=original_tool,
138+
session_id=transport_session_id,
136139
)
137-
result = dispatch(tool_name, call.args)
140+
prepared = posthog.prepare_tool_result(dispatch(tool_name, call.args), call)
138141
posthog.capture_tool_call(
139142
tool_name,
140143
llm_model=call.llm_model,
141144
llm_model_source=call.llm_model_source,
145+
session_id=prepared.session_id,
146+
conversation_id=prepared.conversation_id,
142147
)
148+
return prepared.result
143149
```
144150

145151
Passing `original_tool` keeps ownership accurate when `tools/list` and
146152
`tools/call` reach different server replicas. A persistent single-process
147153
dispatcher can omit it after calling `prepare_tool_list()`.
148-
Model injection copies tool objects instead of changing their original schemas.
154+
Model and conversation injection copy tool objects instead of changing their original schemas.
149155
Always advertise the returned list and pass the original application tool to
150156
`prepare_tool_call()`. Repeatedly preparing the original list preserves ownership.
151157

158+
Pass an existing transport or request session as `session_id`. A valid echoed
159+
`conversation_id` takes precedence. Otherwise the existing session stays, and no new handle is
160+
minted. `prepare_tool_result()` appends a new handle to the result's `content` and mirrors it
161+
into `structuredContent` when the tool declares an output schema. If the result has no channel
162+
that can carry a new handle, `conversation_id` is `None` and the derived `session_id` is kept.
163+
Set `enable_conversation_id=False` on `PostHogMCP` to keep the previous custom-dispatcher
164+
behavior.
165+
152166
## Collect agent feedback
153167

154168
Feedback collection is off by default. Enable it to advertise a `send_feedback`
@@ -221,8 +235,14 @@ tools = posthog.prepare_tool_list(server_tools, collect_feedback=True)
221235
# tools/call dispatcher
222236
call = posthog.prepare_tool_call(tool_name, raw_args)
223237
if call.is_feedback:
224-
posthog.capture_feedback(report=call.feedback_report) # emits $mcp_feedback
225-
return send_feedback_result() # replies to the agent and stops dispatch
238+
# Replies to the agent and stops dispatch.
239+
prepared = posthog.prepare_tool_result(send_feedback_result(), call)
240+
posthog.capture_feedback( # emits $mcp_feedback
241+
report=call.feedback_report,
242+
session_id=prepared.session_id,
243+
conversation_id=prepared.conversation_id,
244+
)
245+
return prepared.result
226246
```
227247

228248
`on_feedback` is ignored on this path — the dispatcher routes reports itself via

‎posthog/mcp/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
MCPAnalyticsModelSource,
8888
MCPAnalyticsOptions,
8989
PreparedToolCall,
90+
PreparedToolResult,
9091
UserIdentity,
9192
)
9293
from .version import __version__
@@ -104,6 +105,7 @@
104105
"CollectFeedbackOptions",
105106
"FeedbackReport",
106107
"PreparedToolCall",
108+
"PreparedToolResult",
107109
"get_more_tools_result",
108110
"send_feedback_result",
109111
"SEND_FEEDBACK_TOOL_NAME",

‎posthog/mcp/_conversation_id.py‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def add_conversation_id_to_schema(
4242
and isinstance(schema.get("properties"), dict)
4343
and CONVERSATION_ID_PARAM_NAME in schema["properties"]
4444
):
45+
if _is_our_declaration(schema["properties"][CONVERSATION_ID_PARAM_NAME]):
46+
return schema
4547
log(
4648
f"WARN: Tool \"{tool_name}\" already has '{CONVERSATION_ID_PARAM_NAME}'. Skipping injection."
4749
)
@@ -64,6 +66,26 @@ def add_conversation_id_to_schema(
6466
return schema
6567

6668

69+
def can_inject_conversation_id(input_schema: Any) -> bool:
70+
"""Whether the SDK can own ``conversation_id`` on this input schema. An
71+
application-declared field or a composed schema stays the application's,
72+
so its value is never read as a handle or stripped before dispatch."""
73+
if not isinstance(input_schema, dict):
74+
return True
75+
properties = input_schema.get("properties")
76+
if isinstance(properties, dict) and CONVERSATION_ID_PARAM_NAME in properties:
77+
return _is_our_declaration(properties[CONVERSATION_ID_PARAM_NAME])
78+
return not any(input_schema.get(key) for key in ("$ref", "oneOf", "allOf", "anyOf"))
79+
80+
81+
def _is_our_declaration(declaration: Any) -> bool:
82+
return (
83+
isinstance(declaration, dict)
84+
and declaration.get("type") == "string"
85+
and declaration.get("description") == DEFAULT_CONVERSATION_ID_DESCRIPTION
86+
)
87+
88+
6789
def extract_conversation_id(args: Any) -> Optional[str]:
6890
if not isinstance(args, dict):
6991
return None
@@ -116,9 +138,60 @@ def build_prompt_back(conversation_id: str) -> Dict[str, Any]:
116138

117139

118140
def inject_prompt_back(result: Any, conversation_id: str) -> Any:
119-
if not can_inject_prompt_back(result):
141+
"""Append a handle block to a result copy when it has content."""
142+
block: Any = build_prompt_back(conversation_id)
143+
if isinstance(result, dict):
144+
if not isinstance(result.get("content"), list):
145+
return result
146+
return {**result, "content": [*result["content"], block]}
147+
if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], list):
148+
return ([*result[0], block], result[1])
149+
if isinstance(result, list):
150+
return [*result, block]
151+
152+
target = getattr(result, "root", result)
153+
content = getattr(target, "content", None)
154+
if not isinstance(content, list):
120155
return result
121-
return {
122-
**result,
123-
"content": [*result["content"], build_prompt_back(conversation_id)],
124-
}
156+
try:
157+
import mcp.types as mcp_types # noqa: PLC0415
158+
159+
block = mcp_types.TextContent(type="text", text=block["text"])
160+
except ImportError:
161+
pass
162+
163+
copy_model = getattr(target, "model_copy", None)
164+
if callable(copy_model):
165+
try:
166+
updated = copy_model(update={"content": [*content, block]})
167+
except Exception: # noqa: BLE001 - delivery must not break a tool call
168+
return result
169+
else:
170+
updated = _copy_with_attr(target, "content", [*content, block])
171+
if updated is None:
172+
return result
173+
if target is result:
174+
return updated
175+
176+
rewrap = getattr(result, "model_copy", None)
177+
if callable(rewrap):
178+
try:
179+
return rewrap(update={"root": updated})
180+
except Exception: # noqa: BLE001 - delivery must not break a tool call
181+
return result
182+
wrapped = _copy_with_attr(result, "root", updated)
183+
return result if wrapped is None else wrapped
184+
185+
186+
def _copy_with_attr(value: Any, attr: str, updated: Any) -> Optional[Any]:
187+
try:
188+
copied = copy.copy(value)
189+
except Exception: # noqa: BLE001 - delivery must not break a tool call
190+
return None
191+
if copied is value:
192+
return None
193+
try:
194+
setattr(copied, attr, updated)
195+
except Exception: # noqa: BLE001 - read-only objects fail closed
196+
return None
197+
return copied

‎posthog/mcp/_output_instructions.py‎

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,19 @@ def add_instructions_to_output_schema(tool: Any) -> bool:
123123
)
124124
return False
125125

126+
try:
127+
setattr(tool, attr, declare_output_instructions(original))
128+
except Exception: # noqa: BLE001 - some schema attrs may be read-only
129+
log(f"WARN: could not set {attr} on tool {name}")
130+
return False
131+
return True
132+
133+
134+
def declare_output_instructions(output_schema: Dict[str, Any]) -> Dict[str, Any]:
135+
"""A copy of ``output_schema`` with the optional :data:`MCP_INSTRUCTIONS_KEY`
136+
declared. Callers check :func:`can_declare_output_instructions` first."""
126137
# Deep copy: the server may reuse or freeze the schema object it handed us.
127-
schema = copy.deepcopy(original)
138+
schema = copy.deepcopy(output_schema)
128139
if not isinstance(schema.get("properties"), dict):
129140
schema["properties"] = {}
130141
schema["properties"][MCP_INSTRUCTIONS_KEY] = {
@@ -137,12 +148,14 @@ def add_instructions_to_output_schema(tool: Any) -> bool:
137148
}
138149
},
139150
}
140-
try:
141-
setattr(tool, attr, schema)
142-
except Exception: # noqa: BLE001 - some schema attrs may be read-only
143-
log(f"WARN: could not set {attr} on tool {name}")
144-
return False
145-
return True
151+
return schema
152+
153+
154+
def tool_output_schema(tool: Any) -> Any:
155+
"""A tool's advertised output schema, whether it is a dict or an SDK model."""
156+
if isinstance(tool, dict):
157+
return tool.get("outputSchema")
158+
return _read_attr(tool, _OUTPUT_SCHEMA_ATTRS)[1]
146159

147160

148161
def build_conversation_instructions(conversation_id: str) -> Dict[str, Any]:
@@ -205,17 +218,35 @@ def mirror_instructions_into_structured_content(
205218
new_target = copy_model(update={attr: updated})
206219
except Exception: # noqa: BLE001 - never let delivery break the tool path
207220
return result, False
208-
if target is result:
209-
return new_target, True
210-
rewrap = getattr(result, "model_copy", None)
211-
if callable(rewrap):
212-
try:
213-
return rewrap(update={"root": new_target}), True
214-
except Exception: # noqa: BLE001
215-
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:
216235
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."""
217242
try:
218-
setattr(target, attr, updated)
219-
except Exception: # noqa: BLE001 - never let delivery break the tool path
220-
return result, False
221-
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

0 commit comments

Comments
 (0)