Skip to content

Commit 94ac113

Browse files
committed
fix(mcp): deliver the conversation handle via structuredContent too
Manual testing against Claude Code found the conversation feature inert for any tool that declares an output schema: clients that read structuredContent never render the content blocks, so the [SERVER]: Reuse conversation_id text block was invisible and the agent had nothing to echo back. Every call minted a fresh handle, and each landed in its own session. Ports the second delivery channel from @posthog/mcp (ADR-0004, posthog-js #4430/#4431), which measured the same 0% echo rate before fixing it: - declare an optional `_mcp_instructions` key on the tool's advertised outputSchema at tools/list (never `required`) - mirror {conversation_id} into the result's structuredContent on *every* response, not just the minting one, so an agent that dropped the handle can read it back The declaration is what makes the write safe — clients validate structuredContent against the advertised schema, so an undeclared key fails the customer's whole tool result under additionalProperties: false. Only tools we declared on are ever written to, and an instance that never served a tools/list fails closed. Composed schemas (oneOf/allOf/anyOf/$ref) and tools owning the key are skipped; the text block still carries them. Wired into all four adapters (v1 FastMCP, v1 low-level, jlowin fastmcp, v2) with shape-tolerant reads: the (content, structured) tuple from FastMCP 1.x's convert_result path, CallToolResult models (structuredContent on 1.x, structured_content on 2.x), and plain dicts. Verified on the live playground server: outputSchema declares the key and structuredContent carries the handle alongside the tool's own payload. Generated-By: PostHog Code Task-Id: ebafcb71-b03b-443d-b40c-d527ed4a04f4
1 parent fddfaf1 commit 94ac113

9 files changed

Lines changed: 608 additions & 27 deletions

.sampo/changesets/mcp-sdk-v2-support.md

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

5-
feat(mcp): support the MCP Python SDK 2.x and the 2026-07-28 spec revision. `instrument()` now wraps `mcp.server.mcpserver.MCPServer` (the renamed FastMCP) and the v2 low-level `Server` (constructor-injected handlers, string-keyed registry, late `add_request_handler` registrations included), capturing tool calls, tools/list, errors, intent, client identity, and `$mcp_protocol_version` on both protocol eras — SDK 1.x paths are unchanged. Conversation-anchored sessions land as the cross-pod correlation for the stateless era (parity with `@posthog/mcp`): with `enable_conversation_id`, `$session_id` is derived deterministically from the agent-echoed `conversation_id` (new export `derive_session_id_from_conversation`), only handles the SDK could have minted (uuidv7) anchor, and the prompt-back now rides errored results too so a first-call failure keeps the conversation together. `instrument()` also no longer crashes on an unsupported or unrecognized MCP SDK — it degrades to a logged no-op.
5+
feat(mcp): support the MCP Python SDK 2.x and the 2026-07-28 spec revision. `instrument()` now wraps `mcp.server.mcpserver.MCPServer` (the renamed FastMCP) and the v2 low-level `Server` (constructor-injected handlers, string-keyed registry, late `add_request_handler` registrations included), capturing tool calls, tools/list, errors, intent, client identity, and `$mcp_protocol_version` on both protocol eras — SDK 1.x paths are unchanged. Conversation-anchored sessions land as the cross-pod correlation for the stateless era (parity with `@posthog/mcp`): with `enable_conversation_id`, `$session_id` is derived deterministically from the agent-echoed `conversation_id` (new export `derive_session_id_from_conversation`), only handles the SDK could have minted (uuidv7) anchor, and the prompt-back now rides errored results too so a first-call failure keeps the conversation together. The handle is delivered over both channels a tool result has — a `content` text block on the minting response, and an `_mcp_instructions` key declared on the tool's `outputSchema` and mirrored into `structuredContent` on every response. The second channel is what makes the feature work for tools with structured output at all: clients that read `structuredContent` never render `content`, so the agent had no handle to echo (0% echo rate measured against Claude Code before the mirror). `instrument()` also no longer crashes on an unsupported or unrecognized MCP SDK — it degrades to a logged no-op.

posthog/mcp/_instrument_fastmcp.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
resolve_session_and_client,
5151
)
5252
from ._internal import MCPAnalyticsData
53+
from ._output_instructions import (
54+
add_instructions_to_output_schema,
55+
mirror_instructions_into_structured_content,
56+
)
5357
from .logger import log
5458
from .tools import (
5559
GET_MORE_TOOLS_NAME as _GET_MORE_TOOLS_NAME,
@@ -178,16 +182,26 @@ async def wrapped(
178182
)
179183
raise
180184

181-
# Inject the prompt-back first, then capture the delivered result. Only stamp
182-
# a minted conversation_id when it was actually appended to what the agent got.
185+
# Deliver the handle first, then capture the result the agent actually got.
186+
# Two channels: mirrored into structuredContent on every response (for
187+
# tools whose output schema we declared the key on — clients that read
188+
# structuredContent never see the text block), and the prompt-back text
189+
# block on the minting response only.
183190
delivered_conversation_id = conversation_id
184-
if minted and conversation_id:
185-
injected = _inject_prompt_back(result, conversation_id)
186-
if injected is result:
187-
delivered_conversation_id = (
188-
None # not injectable (e.g. tuple/scalar result)
191+
if conversation_id:
192+
delivered = False
193+
if data.tool_output_instructions.get(name):
194+
result, delivered = mirror_instructions_into_structured_content(
195+
result, conversation_id
189196
)
190-
result = injected
197+
if minted:
198+
injected = _inject_prompt_back(result, conversation_id)
199+
if injected is not result:
200+
delivered = True
201+
result = injected
202+
# Only a minted handle can be lost — one the agent supplied, it has.
203+
if not delivered:
204+
delivered_conversation_id = None
191205

192206
await record_tool_call(
193207
data,
@@ -298,6 +312,13 @@ async def list_handler(req: Any) -> Any:
298312
tool.inputSchema = schema
299313
except Exception: # noqa: BLE001 - some schema attrs may be read-only
300314
log(f"WARN: could not set inputSchema on tool {tool.name}")
315+
# Declare the structuredContent channel and remember the answer:
316+
# clients that read structuredContent never see the content text
317+
# block, and only a declared key may be written back on a call.
318+
if data.options.enable_conversation_id:
319+
data.tool_output_instructions[tool.name] = (
320+
add_instructions_to_output_schema(tool)
321+
)
301322

302323
if data.options.report_missing:
303324
missing_name = resolve_missing_capability_tool_name(data.options)

posthog/mcp/_instrument_lowlevel.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
resolve_session_and_client,
4545
)
4646
from ._internal import MCPAnalyticsData
47+
from ._output_instructions import (
48+
add_instructions_to_output_schema,
49+
mirror_instructions_into_structured_content,
50+
)
4751
from .logger import log
4852
from .tools import (
4953
GET_MORE_TOOLS_NAME as _GET_MORE_TOOLS_NAME,
@@ -188,22 +192,33 @@ async def handler(req: Any) -> Any:
188192
# CallToolResult(isError=True); record_tool_call detects that from the result.
189193
call_result = getattr(result, "root", result)
190194

191-
# Inject the prompt-back before capture; only stamp a minted conversation_id
192-
# when it was actually delivered (non-list results can't carry it), so we
193-
# don't record an orphan id the agent never received. Errored results carry
194-
# it on purpose: a first-call failure is exactly when the agent needs the
195-
# handle, or the retry starts a fresh conversation.
195+
# Deliver the handle before capture, over both channels a result has:
196+
# mirrored into structuredContent on every response (for tools whose
197+
# output schema we declared the key on — clients that read
198+
# structuredContent never see the text block), and the prompt-back text
199+
# block on the minting response only. Only stamp a minted conversation_id
200+
# when it actually reached the agent, so we don't record an orphan id.
201+
# Errored results carry it on purpose: a first-call failure is exactly
202+
# when the agent needs the handle, or the retry starts a fresh conversation.
196203
delivered_conversation_id = conversation_id
197-
if minted and conversation_id:
198-
content = getattr(call_result, "content", None)
199-
if isinstance(content, list):
200-
content.append(
201-
mcp_types.TextContent(
202-
type="text", text=build_prompt_back(conversation_id)["text"]
203-
)
204+
if conversation_id:
205+
delivered = False
206+
if data.tool_output_instructions.get(name):
207+
_, delivered = mirror_instructions_into_structured_content(
208+
call_result, conversation_id
204209
)
205-
else:
206-
delivered_conversation_id = None
210+
if minted:
211+
content = getattr(call_result, "content", None)
212+
if isinstance(content, list):
213+
content.append(
214+
mcp_types.TextContent(
215+
type="text", text=build_prompt_back(conversation_id)["text"]
216+
)
217+
)
218+
delivered = True
219+
# Only a minted handle can be lost — one the agent supplied, it has.
220+
if not delivered:
221+
delivered_conversation_id = None
207222

208223
await record_tool_call(
209224
data,
@@ -316,6 +331,13 @@ async def handler(req: Any) -> Any:
316331
tool.inputSchema = schema
317332
except Exception: # noqa: BLE001
318333
log(f"WARN: could not set inputSchema on tool {tool.name}")
334+
# Declare the structuredContent channel and remember the answer:
335+
# clients that read structuredContent never see the content text
336+
# block, and only a declared key may be written back on a call.
337+
if data.options.enable_conversation_id:
338+
data.tool_output_instructions[tool.name] = (
339+
add_instructions_to_output_schema(tool)
340+
)
319341

320342
if data.options.report_missing:
321343
missing_name = resolve_missing_capability_tool_name(data.options)

posthog/mcp/_instrument_v2.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
resolve_session_and_client,
5757
)
5858
from ._internal import MCPAnalyticsData
59+
from ._output_instructions import (
60+
add_instructions_to_output_schema,
61+
mirror_instructions_into_structured_content,
62+
)
5963
from .logger import log
6064
from .tools import (
6165
GET_MORE_TOOLS_NAME as _GET_MORE_TOOLS_NAME,
@@ -323,8 +327,12 @@ async def wrapped(
323327
duration_ms = (time.monotonic() - start) * 1000
324328

325329
delivered_conversation_id = conversation_id
326-
if minted and conversation_id:
327-
if not _append_prompt_back(result, conversation_id):
330+
if conversation_id:
331+
result, delivered = _deliver_conversation_id(
332+
data, result, name, conversation_id, minted
333+
)
334+
# Only a minted handle can be lost this way — one the agent supplied, it has.
335+
if minted and not delivered:
328336
delivered_conversation_id = None
329337

330338
await record_tool_call(
@@ -365,6 +373,24 @@ def _append_prompt_back(result: Any, conversation_id: str) -> bool:
365373
return False
366374

367375

376+
def _deliver_conversation_id(
377+
data: MCPAnalyticsData, result: Any, name: str, conversation_id: str, minted: bool
378+
) -> Tuple[Any, bool]:
379+
"""Hand the conversation handle back over both channels a result has:
380+
mirrored into ``structuredContent`` on every response (for tools whose
381+
output schema we declared the key on), and as a ``content`` text block on
382+
the minting response only. Returns ``(result, delivered)`` — a minted handle
383+
the agent never received must not be stamped on the event."""
384+
delivered = False
385+
if data.tool_output_instructions.get(name):
386+
result, delivered = mirror_instructions_into_structured_content(
387+
result, conversation_id
388+
)
389+
if minted and _append_prompt_back(result, conversation_id):
390+
delivered = True
391+
return result, delivered
392+
393+
368394
# --- low-level: tools/call ------------------------------------------------------
369395

370396

@@ -444,8 +470,12 @@ async def handler(ctx: Any, params: Any) -> Any:
444470
duration_ms = (time.monotonic() - start) * 1000
445471

446472
delivered_conversation_id = conversation_id
447-
if minted and conversation_id:
448-
if not _append_prompt_back(result, conversation_id):
473+
if conversation_id:
474+
result, delivered = _deliver_conversation_id(
475+
data, result, name, conversation_id, minted
476+
)
477+
# Only a minted handle can be lost this way — one the agent supplied, it has.
478+
if minted and not delivered:
449479
delivered_conversation_id = None
450480

451481
await record_tool_call(
@@ -562,6 +592,13 @@ async def handler(ctx: Any, params: Any) -> Any:
562592
tool.input_schema = schema
563593
except Exception: # noqa: BLE001 - some schema attrs may be read-only
564594
log(f"WARN: could not set input_schema on tool {tool.name}")
595+
# Declare the structuredContent channel and remember the answer:
596+
# clients that read structuredContent never see the content text
597+
# block, and only a declared key may be written back on a call.
598+
if data.options.enable_conversation_id:
599+
data.tool_output_instructions[tool.name] = (
600+
add_instructions_to_output_schema(tool)
601+
)
565602

566603
if data.options.report_missing:
567604
missing_name = resolve_missing_capability_tool_name(data.options)

posthog/mcp/_internal.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ class MCPAnalyticsData:
6666
identified_sessions: IdentityCache = field(default_factory=IdentityCache)
6767
tool_categories: Dict[str, str] = field(default_factory=dict)
6868
tool_descriptions: Dict[str, str] = field(default_factory=dict)
69+
# Which tools got `_mcp_instructions` declared on their advertised output
70+
# schema at tools/list. Only those may be mirrored into on a call — writing
71+
# an undeclared key fails the customer's whole result under
72+
# `additionalProperties: false`. Absent means "never served a listing for
73+
# this tool", which fails closed.
74+
tool_output_instructions: Dict[str, bool] = field(default_factory=dict)
6975
# Bounded FIFO of sessions we've emitted $mcp_initialize for, so a long-lived
7076
# server can't accumulate one entry per session forever.
7177
initialized_sessions: "OrderedDict[str, None]" = field(default_factory=OrderedDict)

0 commit comments

Comments
 (0)