Skip to content

Commit 899a219

Browse files
committed
fix(mcp): plug feedback-tool-shadow and error-leak gaps from review
- _matches_extra_schema: a declared "integer" extra accepted any float, including fractional ones (3.5), since Python's numeric tower conflates int and float; now requires the value to be whole. - handle_feedback: an on_feedback exception was logged with str(error) verbatim, letting agent-controlled report text (PII, credentials, log-forging newlines) an error message echoes reach host logs; now logs only the exception type, matching the report log beside it. - start_tool_call_lifecycle: conversation-id resolution skipped every call named like the feedback tool regardless of the listing-derived shadow flag, so a real tool that collided with the configured feedback name never got a conversation id even once ownership was known; now mirrors ToolCallLifecycle.is_feedback's fail-open guard. Addresses greptile-apps findings on PR #939. Generated-By: PostHog Desktop Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
1 parent 5e9ed76 commit 899a219

4 files changed

Lines changed: 106 additions & 4 deletions

File tree

‎posthog/mcp/_instrumentation.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,11 @@ def start_tool_call_lifecycle(
553553
feedback_options = resolve_collect_feedback_options(data.options.collect_feedback)
554554
feedback_name = (
555555
resolve_send_feedback_tool_name(feedback_options)
556-
if feedback_options is not None
556+
# Mirrors `ToolCallLifecycle.is_feedback`'s fail-open guard below: once a
557+
# real application tool is known to own this name, conversation-id
558+
# resolution must treat calls to it like any other tool too, not skip
559+
# them as if they were the (shadowed) virtual feedback tool.
560+
if feedback_options is not None and not data.feedback_tool_shadowed
557561
else None
558562
)
559563
conversation_id, minted = resolve_conversation_id(

‎posthog/mcp/feedback.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,15 @@ def _matches_extra_schema(value: Any, schema: Dict[str, Any]) -> bool:
207207
else:
208208
actual = "object"
209209
declared = schema.get("type")
210-
if declared != actual and not (declared == "integer" and actual == "number"):
210+
if declared == "integer" and actual == "number":
211+
# A JSON Schema `integer` also accepts a whole-valued float (`3.0`), but
212+
# not a fractional one (`3.5`) - Python's numeric tower conflates int and
213+
# float here, so the type name alone can't tell them apart.
214+
if not (
215+
isinstance(value, int) or (isinstance(value, float) and value.is_integer())
216+
):
217+
return False
218+
elif declared != actual:
211219
return False
212220
enum = schema.get("enum")
213221
return not isinstance(enum, list) or value in enum
@@ -352,8 +360,13 @@ async def handle_feedback(
352360
if isinstance(reply, str) and reply.strip():
353361
return reply
354362
except Exception as error: # noqa: BLE001 - never break the agent's turn
363+
# Only the exception's type, matching the report log above: a
364+
# handler can echo the unsanitized report (PII, credentials,
365+
# log-forging newlines, unbounded length) into its error message,
366+
# and that agent-controlled text does not belong in host logs
367+
# any more than `report.summary` does.
355368
log(
356-
"Warning: on_feedback handler threw; returning the default "
357-
f"acknowledgement - {error}"
369+
"Warning: on_feedback handler threw "
370+
f"({type(error).__name__}); returning the default acknowledgement"
358371
)
359372
return _SEND_FEEDBACK_RESULT_TEXT

‎posthog/test/mcp/test_feedback.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,28 @@ def test_extras_must_match_declared_type_and_enum():
304304
assert conforming.extras == {"score": 9, "channel": "web"}
305305

306306

307+
def test_extras_declared_integer_rejects_fractional_float():
308+
# `isinstance(value, (int, float))` alone can't tell `3` from `3.5` - both
309+
# are Python `number`s - so a declared `integer` extra must additionally
310+
# check the value has no fractional part before it's trusted downstream.
311+
options = CollectFeedbackOptions(extra_properties={"score": {"type": "integer"}})
312+
313+
fractional = parse_feedback_report(
314+
{"feedback_type": "praise", "summary": "s", "score": 3.5}, options
315+
)
316+
assert fractional.extras == {}
317+
318+
whole_float = parse_feedback_report(
319+
{"feedback_type": "praise", "summary": "s", "score": 3.0}, options
320+
)
321+
assert whole_float.extras == {"score": 3.0}
322+
323+
whole_int = parse_feedback_report(
324+
{"feedback_type": "praise", "summary": "s", "score": 3}, options
325+
)
326+
assert whole_int.extras == {"score": 3}
327+
328+
307329
def test_tool_name_gets_pii_redaction():
308330
report = parse_feedback_report(
309331
{"feedback_type": "issue", "summary": "s", "tool_name": "ask jane@example.com"}
@@ -588,6 +610,42 @@ def on_feedback(report):
588610
assert _events(client, "$mcp_feedback")
589611

590612

613+
async def test_on_feedback_raise_does_not_log_agent_text():
614+
# A raising backend can echo the unsanitized report (PII, credentials,
615+
# forged newlines) into its exception message; the warning log must not
616+
# repeat it, mirroring the report log just above it that logs only the type.
617+
from posthog.mcp import set_logger
618+
619+
server = make_fastmcp()
620+
client = FakeClient()
621+
secret_summary = "credit card 4242-4242-4242-4242 jane@example.com"
622+
623+
def on_feedback(report):
624+
raise RuntimeError(f"backend rejected: {report.summary}")
625+
626+
instrument(
627+
server,
628+
client,
629+
MCPAnalyticsOptions(
630+
collect_feedback=CollectFeedbackOptions(on_feedback=on_feedback)
631+
),
632+
)
633+
634+
messages = []
635+
set_logger(messages.append)
636+
try:
637+
canned = await server._tool_manager.call_tool(
638+
"send_feedback", {**dict(_REPORT_ARGS), "summary": secret_summary}
639+
)
640+
await _flush()
641+
finally:
642+
set_logger(None)
643+
644+
assert canned[0].text == send_feedback_result_text()
645+
assert any("on_feedback handler threw" in message for message in messages)
646+
assert not any(secret_summary in message for message in messages)
647+
648+
591649
# --- instrument(): low-level v1 ----------------------------------------------------
592650

593651

‎posthog/test/mcp/test_v2_mcpserver.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,33 @@ def send_feedback(note: str) -> str:
429429
assert _events(client, "$mcp_tool_call")
430430

431431

432+
async def test_collect_feedback_collision_keeps_conversation_id():
433+
# The name collision must fail open for every feature keyed off the
434+
# feedback tool name, not just dispatch - conversation-id resolution used
435+
# to keep skipping the real tool because it checked the configured name
436+
# alone, ignoring the listing-derived shadow flag.
437+
server = make_server()
438+
439+
@server.tool()
440+
def send_feedback(note: str) -> str:
441+
return f"real tool got {note}"
442+
443+
client = FakeClient()
444+
instrument(
445+
server,
446+
client,
447+
MCPAnalyticsOptions(collect_feedback=True, enable_conversation_id=True),
448+
)
449+
450+
await _list_tools(server)
451+
await _call_tool(server, "send_feedback", {"note": "hi", "context": "real tool"})
452+
await _flush()
453+
454+
calls = _events(client, "$mcp_tool_call")
455+
assert len(calls) == 1
456+
assert calls[0]["properties"].get("$mcp_conversation_id")
457+
458+
432459
async def test_instrument_is_idempotent():
433460
server = make_server()
434461
client = FakeClient()

0 commit comments

Comments
 (0)