Skip to content

feat(mcp): capture agents feedback - #939

Merged
gesh merged 9 commits into
mainfrom
posthog/mcp-send-feedback-tool
Sep 11, 2026
Merged

feat(mcp): capture agents feedback#939
gesh merged 9 commits into
mainfrom
posthog/mcp-send-feedback-tool

Conversation

@gesh

@gesh gesh commented Sep 10, 2026

Copy link
Copy Markdown
Member

Problem

Agents constantly hit gaps our analytics never see: a tool they needed that does not exist, a tool that confused them, an error message that did not help. The existing get_more_tools tool only covers capability gaps and promises "more tools" it never returns. Server owners need one honest, structured channel for agent feedback.

This is the Python port of PostHog/posthog-js#4870, which shipped the same feature in @posthog/mcp.

Changes

New opt-in collect_feedback option in posthog.mcp. It injects a send_feedback virtual tool and captures every call as a $mcp_feedback event. Additive: report_missing / get_more_tools / $mcp_missing_capability are unchanged.

Enable it

from posthog.mcp import MCPAnalyticsOptions, instrument

instrument(server, posthog, MCPAnalyticsOptions(collect_feedback=True))

Works on all instrumented server types (MCP SDK 1.x FastMCP, 2.x MCPServer, both low-level servers, standalone fastmcp).

What the agent sends

// tools/call → send_feedback
{
  "feedback_type": "missing_capability", // "missing_capability" | "issue" | "praise" | "other" (required)
  "summary": "No tool to delete multiple cohorts in one call.", // one sentence (required)
  "details": "Deleted 20 cohorts one by one via cohort-delete.",
  "friction_points": "cohort-delete accepts a single id; no batch variant.",
  "suggested_improvement": "Add a bulk delete tool.",
  "tool_name": "cohort-delete", // for "issue"/"praise"; empty for "missing_capability"
  "sentiment": "negative", // "positive" | "neutral" | "negative" | "mixed"
  "task_completed": true
}

The tool description makes missing capabilities the priority category and says plainly that the call records feedback and adds no tools. The reply is an honest acknowledgement.

What lands in PostHog

One $mcp_feedback event (never a $mcp_tool_call) with $mcp_feedback_type, $mcp_feedback_summary, $mcp_feedback_details, $mcp_feedback_friction_points, $mcp_feedback_suggested_improvement, $mcp_feedback_tool, $mcp_feedback_sentiment, $mcp_feedback_task_completed. The summary + details also map to $mcp_intent. Free-text fields get the $mcp_intent pipeline: sanitize, structured-PII redaction, length bound — and the event carries no $mcp_parameters, so the raw arguments are never captured. Invalid feedback_type falls back to other.

Extra properties + a real backend

from posthog.mcp import CollectFeedbackOptions, MCPAnalyticsOptions

instrument(server, posthog, MCPAnalyticsOptions(
    collect_feedback=CollectFeedbackOptions(
        tool_name="send_feedback",  # rename if you want
        extra_properties={
            "product_area": {"type": "string", "description": "The product the feedback is about."},
        },
        extra_required=["product_area"],
        on_feedback=record_feedback_in_backend,  # sync or async; return a str to replace the default reply
    ),
))

Declared extras merge into the advertised schema and are captured as $mcp_feedback_<key>. Arguments the agent invents are never captured (handler-only via report.raw). A key that collides with a core field raises at setup. A raised on_feedback logs and falls back to the default reply; the event is captured either way.

Custom dispatcher (PostHogMCP)

posthog = PostHogMCP(token, collect_feedback=True)

# tools/list handler
tools = posthog.prepare_tool_list(my_tools, collect_feedback=True)  # advertises send_feedback

# tools/call dispatcher
call = posthog.prepare_tool_call(name, raw_args)
if call.is_feedback:
    posthog.capture_feedback(report=call.feedback_report)  # emits $mcp_feedback
    return send_feedback_result()  # replies to the agent and stops dispatch

Same mechanics as get_more_tools: fail-open on real-tool name collisions (a listing-derived shadow flag plus a live registry probe on the high-level adapters), no context/conversation_id injection on the virtual tool, llm_model capture still works on it, and no conversation handle is minted for feedback calls.

How did you test this code?

New posthog/test/mcp/test_feedback.py (37 tests): descriptor build + reserved-key/extra_required validation, deep-copy independence, parse fallbacks, $mcp_feedback capture with all properties, PII redaction + length bounds, extras capture (declared only), intent building, instrument() end-to-end on FastMCP + low-level, collision fail-open (with and without a prior listing), on_feedback custom/async/raising handlers, custom tool name, coexistence with report_missing, no conversation-id minting, and the full PostHogMCP path. Plus four mirror tests in the MCP SDK 2.x suites (test_v2_mcpserver.py, test_v2_lowlevel.py).

Full runs: pytest posthog/test/mcp green on the mcp 1.x leg (344 passed) and the mcp 2.x leg (289 passed), full repo suite, ruff format --check, ruff check, mypy (baseline-filtered, no new issues), and the public-API snapshot regenerated + checked.


Created with PostHog Desktop

Add an opt-in collect_feedback option that injects a send_feedback
virtual tool and captures every call as a $mcp_feedback event, porting
PostHog/posthog-js#4870 to the Python MCP analytics SDK.

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-09-11 15:02:00 UTC
Duration: 256062ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint 514ms
Endpoint And Method.Does Not Use Legacy Endpoints 509ms
Required Headers.Has Authorization Bearer Header 509ms
Required Headers.Has Content Type Json 508ms
Required Headers.Has Posthog Sdk Info Format 509ms
Required Headers.Has Posthog Attempt Header 508ms
Required Headers.Has Posthog Request Id 508ms
Required Headers.Has Posthog Request Timestamp 509ms
Required Headers.Has User Agent 508ms
Body Format.Body Has Created At And Batch 508ms
Body Format.No Api Key In Body 510ms
Body Format.No Sent At In Body 509ms
Event Format.Event Has Required Root Fields 508ms
Event Format.Event Uuid Is Valid 508ms
Event Format.Event Timestamp Is Rfc3339 508ms
Event Format.Distinct Id Is String 509ms
Event Format.Distinct Id At Root Not Properties 508ms
Event Format.Custom Properties Preserved 509ms
Event Format.Set Properties Preserved 508ms
Event Format.Set Once Properties Preserved 508ms
Event Format.Groups Properties Preserved 509ms
Event Format.Sdk Generates Uuid If Not Provided 508ms
Event Format.Event Has Required Root Fields Batch 511ms
Event Format.Event Uuid Is Valid Batch 511ms
Event Format.Event Timestamp Is Rfc3339 Batch 511ms
Event Format.Distinct Id Is String Batch 511ms
Event Format.Distinct Id At Root Not Properties Batch 510ms
Event Format.Custom Properties Preserved Batch 511ms
Event Format.Set Properties Preserved Batch 511ms
Event Format.Set Once Properties Preserved Batch 511ms
Event Format.Groups Properties Preserved Batch 511ms
Event Format.Sdk Generates Uuid If Not Provided Batch 511ms
Batch Behavior.Multiple Events In Single Batch 514ms
Batch Behavior.Batch Envelope Smoke 512ms
Batch Behavior.Flush With No Events Sends Nothing 505ms
Batch Behavior.Flush At Triggers Batch 1009ms
Batch Behavior.Created At Reflects Batch Creation Time 509ms
Deduplication.Generates Unique Uuids 514ms
Deduplication.Different Events Same Content Different Uuids 510ms
Deduplication.Preserves Uuid On Retry 6514ms
Deduplication.Preserves Timestamp On Retry 6513ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 6516ms
Deduplication.No Duplicate Events In Batch 515ms
Header Behavior On Retry.Attempt Header Starts At One 509ms
Header Behavior On Retry.Attempt Header Increments On Retry 13525ms
Header Behavior On Retry.Request Id Preserved On Retry 6513ms
Header Behavior On Retry.Different Requests Have Different Request Ids 3017ms
Header Behavior On Retry.Request Timestamp Changes On Retry 6517ms
Response Format Validation.Success Response Has Uuid Keyed Results 509ms
Response Format Validation.Success Response Has Ok For Each Event 511ms
Response Format Validation.Success No Retry After When All Ok 511ms
Response Format Validation.Success Retry After Present When Retry Events 1513ms
Response Format Validation.Success No Retry After When Drop Only 510ms
Response Format Validation.Response Echoes Request Id 508ms
Retry Behavior.Retries On 408 6517ms
Retry Behavior.Retries On 500 6518ms
Retry Behavior.Retries On 503 8516ms
Retry Behavior.Retries On 504 6517ms
Retry Behavior.Retryable Errors Have Retry After 3514ms
Retry Behavior.Respects Retry After On Retryable Error 11515ms
Retry Behavior.Does Not Retry On 400 2514ms
Retry Behavior.Does Not Retry On 401 2511ms
Retry Behavior.Does Not Retry On 402 2511ms
Retry Behavior.Does Not Retry On 413 2512ms
Retry Behavior.Does Not Retry On 415 2512ms
Retry Behavior.Non Retryable Errors Have No Retry After 2511ms
Retry Behavior.Implements Backoff 22534ms
Retry Behavior.Max Retries Respected 22522ms
Partial Batch Handling.Handles 200 Full Success 2511ms
Partial Batch Handling.Handles 200 With All Ok 3515ms
Partial Batch Handling.Does Not Retry Dropped Events 3513ms
Partial Batch Handling.Does Not Retry Limited Events 3511ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 6519ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 6516ms
Partial Batch Handling.Retries Only Retry Events From Partial 6520ms
Partial Batch Handling.Partial Retry Preserves Uuids 6515ms
Partial Batch Handling.Partial Retry Attempt Header Increments 6515ms
Partial Batch Handling.Partial Retry Request Id Preserved 6515ms
Partial Batch Handling.Respects Retry After On Partial 8515ms
Partial Batch Handling.Unknown Result Treated As Terminal 3513ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 3516ms
Compression.Sends Gzip Content Encoding 510ms
Compression.No Content Encoding When Disabled 509ms
Compression.Compressed Body Is Decompressible 508ms
Error Handling.Does Not Retry On Unknown 4Xx 2511ms
Event Options.Cookieless Mode Override 509ms
Event Options.Disable Skew Correction Override 509ms
Event Options.Process Person Profile Override 508ms
Event Options.Product Tour Id Override 508ms
Event Options.Unset Options Omitted 509ms
Event Options.Options Override In Batch 510ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 508ms
Geoip And Historical Migration.Historical Migration Set In Body 509ms
Geoip And Historical Migration.Historical Migration Absent By Default 508ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 9ms
Request Payload.Flags Request Uses V2 Query Param 8ms
Request Payload.Flags Request Hits Flags Path Not Decide 7ms
Request Payload.Flags Request Omits Authorization Header 7ms
Request Payload.Token In Flags Body Matches Init 7ms
Request Payload.Groups Round Trip 7ms
Request Payload.Groups Default To Empty Object 7ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 7ms
Request Payload.Disable Geoip Omitted Defaults To False 7ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 8ms
Request Lifecycle.No Flags Request On Init Alone 3ms
Request Lifecycle.No Flags Request On Normal Capture 508ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 12ms
Request Lifecycle.Mock Response Value Is Returned To Caller 7ms
Retry Behavior.Retries Flags On 502 312ms
Retry Behavior.Retries Flags On 504 312ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 510ms

- redact structured PII in tool_name like the other free-text fields
- enforce declared type/enum on extras: mismatches stay out of extras
  and the captured properties (raw keeps everything for the handler)
- warn when on_feedback is set on the PostHogMCP path, where it is ignored
- align capture_feedback's property precedence with the instrument() path
  (feedback properties win over caller-supplied ones)
- correct the reserved-keys comment: SDK-injected arguments do reach raw
  because the report is parsed before stripping

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
The TS SDK's sanitizer re-serializes URLs before PII redaction, which
percent-encodes "@" and hides emails from the email pattern. The Python
sanitizer does no URL rewriting, so the case already redacts correctly;
this test keeps it that way if URL scrubbing is ever added.

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
…back-tool

# Conflicts:
#	posthog/mcp/_instrument_lowlevel.py
#	posthog/mcp/_instrument_v2.py
…k capture

Main's URL-credential sanitizer (#928) percent-encodes the @ the email
pattern anchors on, so sanitize-then-redact let PII inside URLs through
the feedback fields. Free text (summary, details, friction_points,
suggested_improvement, tool_name) now uses the $mcp_intent pass
(credentials -> PII -> URLs), and extras use a new sanitize_free_text_value
walker that applies it per string leaf while keeping key-based redaction.
sanitize_intent is renamed sanitize_free_text: the pass is no longer
intent-specific. Ports posthog-js#4870 commits 9d3b3933, f4a69ea8,
270c4630.

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
@gesh
gesh marked this pull request as ready for review September 11, 2026 08:53
@gesh
gesh requested a review from a team as a code owner September 11, 2026 08:53
@gesh
gesh removed the request for review from a team September 11, 2026 08:55
Comment thread posthog/mcp/feedback.py

@gesh gesh left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated comment by QA Swarm — not written by a human

QA Swarm review complete. See inline comments.

Comment thread posthog/mcp/_instrument_v2.py
@veria-ai

veria-ai Bot commented Sep 11, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@gesh

gesh commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

Note

🤖 Automated comment by QA Swarm — not written by a human

Multi-perspective review: router (cheap-first pass) + delegated reviewers (qa-team, paul-reviewer, xp-reviewer, security-audit as warranted)

Verdict: ✅ APPROVE (round 3 @ 899a219)

Round 3 reviewed the review-fix commit (899a219: JSON-Schema-correct integer check for declared extras, exception-type-only logging in the on_feedback catch, and the conversation-id shadow guard) and found no issues: the bool-vs-integer classification order holds, record_feedback's tool-name fallback is unreachable with a None feedback_name, and both CI legs pass (471 tests each). No new findings.

Key findings

None this round.

Convergence

None — single reviewer per round (low-danger increments, nothing delegated).

Reviewer summaries

Reviewer Assessment
🧭 router (sonnet) Verified all three review fixes correct, including the isinstance(True, int) trap and the feedback_name=None consumers. Danger: LOW, confidence: HIGH. Delegated nothing.
Previous rounds (2)

round 1 @ be63255 — ✅ APPROVE: faithful, well-tested port of posthog-js#4870; 1 LOW (raw low-level v2 pre-listing collision window, documented accepted limitation); 469 tests, ruff, mypy clean.
round 2 @ 5e9ed76 — ✅ APPROVE: simplify hoist verified behavior-preserving; no findings.


Automated by QA Swarm — not a human review

@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Security Review

The caught on_feedback exception is logged verbatim even though it may incorporate the unsanitized agent report. This creates a lower-severity risk of feedback containing credentials, PII, or forged log lines being copied into host logs.

Prompt To Fix All With AI
### Issue 1
posthog/mcp/_instrument_lowlevel.py:221-230
**Pre-list calls suppress tools**

Raw low-level servers only learn that a real tool owns the configured feedback name during `tools/list`. If a client invokes that real tool before listing, `feedback_tool_shadowed` is still false and the registry probe cannot identify the tool, so this branch returns a feedback acknowledgement instead of calling the application handler. The real operation is silently suppressed. The raw v2 handler has the same failure at `posthog/mcp/_instrument_v2.py:462-468`.

### Issue 2
posthog/mcp/posthog_mcp.py:426-428
**Dispatcher shadows real tools**

`prepare_tool_list` avoids injecting the virtual descriptor when a real tool has the feedback name, but that decision is not retained. `prepare_tool_call` classifies every call with that name as feedback solely because collection is enabled, and `original_tool` is not used to disambiguate it. A dispatcher following the documented `if call.is_feedback: ... return` flow therefore suppresses the colliding real tool instead of dispatching it.

### Issue 3
posthog/mcp/feedback.py:203-213
**Schema matching is inconsistent**

The matcher treats every Python float as satisfying a declared JSON Schema `integer`, so a value such as `3.5` enters `FeedbackReport.extras` even though that field is documented as safe to trust as schema-conforming. It also rejects valid type arrays and type-less fragments because `schema["type"]` must equal one scalar string. As a result, parsed reports can diverge from the full JSON Schema advertised to agents and pass incorrectly typed values to `on_feedback`.

### Issue 4
posthog/mcp/feedback.py:349-351
**Sync callbacks block requests**

The documented synchronous `on_feedback=lambda report: feedback_backend.record(report)` callback executes directly inside the async MCP request handler. `_maybe_await` only awaits an already-created coroutine or future and does not move synchronous work off the event loop. If that backend performs blocking database, network, or lock work, all concurrent MCP requests on the same loop stall until it returns.

### Issue 5
posthog/mcp/feedback.py:354-357
**Exceptions leak feedback text**

The normal feedback log deliberately excludes agent text because it may contain PII, credentials, newlines, or unbounded content, but this exception path logs `str(error)` verbatim. If an `on_feedback` backend raises an error containing `report.summary`, `report.raw`, or a rejected payload, unsanitized agent-controlled feedback is copied into host logs, creating a risk of sensitive-data exposure and forged log lines.

**How this was verified:** The callback receives the unsanitized report, and the logger forwards the interpolated exception text without any redaction layer.

### Issue 6
posthog/mcp/_conversation_id.py:92-97
**Collisions lose conversation tracking**

Conversation resolution skips every call whose name equals the configured feedback name before checking whether a real application tool shadows that name. A colliding real tool can therefore dispatch normally after listing, while still being unable to extract or mint its advertised conversation handle. Calls to that tool lose conversation and session correlation.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(mcp): run free-text PII redaction be..." | Re-trigger Greptile

Comment thread posthog/mcp/_instrument_lowlevel.py
Comment thread posthog/mcp/posthog_mcp.py
Comment thread posthog/mcp/feedback.py
Comment thread posthog/mcp/feedback.py
Comment thread posthog/mcp/feedback.py Outdated
Comment thread posthog/mcp/_conversation_id.py
Compute _is_sdk_virtual_tool once per mutate_tool_schema call instead of
twice per tool per tools/list dispatch.

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
- _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
… prepare_tool_call

On the custom-dispatcher path prepare_tool_list skips injecting the
virtual tool when a real tool owns the feedback name, but prepare_tool_call
still flagged every call by that name as feedback, so the documented
dispatch flow suppressed the real tool. A host-supplied original_tool is
stateless proof a real tool owns the name (the virtual tool never exists
in the host's own list), so it now wins. Without original_tool the name
match stands and the documented remedy is a non-colliding tool_name.

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
Comment thread posthog/mcp/_instrumentation.py Outdated
…pages

Each page of a paginated listing recomputed feedback_tool_shadowed from
that page's tools alone, so a real send_feedback tool listed on page 1
was forgotten by page 2 — the SDK appended its virtual tool and then
swallowed the real tool's calls. The flag is now sticky for the
instrumentation instance's lifetime, and the virtual tool is appended
only to the final page (no nextCursor), so an early page can't advertise
it before a later page reveals the real tool.

Generated-By: PostHog Desktop
Task-Id: 0b5063cb-6fcf-4364-be5e-de945b1448f0
@gesh
gesh merged commit 8f5e8ef into main Sep 11, 2026
42 checks passed
@gesh
gesh deleted the posthog/mcp-send-feedback-tool branch September 11, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants