Skip to content

fix(mcp): inject virtual tools once, on the first tools/list page - #962

Merged
gesh merged 16 commits into
mainfrom
posthog/mcp-virtual-tool-first-page
Sep 17, 2026
Merged

gesh merged 16 commits into
mainfrom
posthog/mcp-virtual-tool-first-page

Conversation

@gesh

@gesh gesh commented Sep 15, 2026

Copy link
Copy Markdown
Member

💡 Motivation and Context

The SDK adds two virtual tools to tools/list: get_more_tools (report_missing) and send_feedback (collect_feedback). A client joins every page into one list. Each tool must therefore appear on exactly one page, and a host tool with the same name must win. Neither rule held.

Ports PostHog/posthog-js#4953 and PostHog/posthog-js#4967 to Python.

Changes

Area Before After
get_more_tools page rule every page first page
send_feedback page rule last page, so clients that ignore nextCursor never saw it first page
Host tool with the same name, first page swallowed, and the agent got PostHog's canned reply the host tool runs, and a warning names the rename option
Ownership check cannot answer the SDK intercepted the call the SDK gives the call to the host
Host reuses one tools/list result object the append changed that object, so both tools stopped being advertised the append copies it
Custom tool names matched literally, so a renamed tool got a stray conversation_id resolved in one place

"First page" means a request with no cursor. An empty string is a valid opaque cursor, so cursor: "" is a continuation page.

One resolver now decides both tools. The enable switch, the name and the warning text live in one place per tool, so the two cannot drift apart. Three warnings (blocked, shadowed, duplicate) each name the option that renames PostHog's tool. Each warning fires once, and reaches the posthog.mcp logger as well as the logger option.

The public API does not change.

One behavior to flag

PostHog shadows a host tool that appears only on a later page. Page one cannot see page two, so PostHog's tool already holds the name by then, and calls to it reach PostHog. @posthog/mcp behaves the same way. The remedy is the rename option, which the warning names.

Scope

This PR covers where the SDK advertises the virtual tools, and what happens when a name collides. Event attribution for a host tool with the same name ($mcp_intent, conversation id) is the stacked follow-up #963. Attribution does not regress here. Before this PR the SDK swallowed such a tool, so it emitted no $mcp_tool_call to attribute.

💚 How did you test it?

  • pytest posthog/test/mcp on both MCP SDK majors: 515 pass on v1, 440 pass and 19 skip on v2.
  • Full suite except posthog/test/ai: 2040 pass.
  • ruff format, ruff check, mypy, and make public_api_check.
  • New tests cover pagination, the three collision cases, both ownership paths, delegation, custom names and reused result objects.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Opus 5 in PostHog Desktop wrote the code and the tests. Four reviewers (greptile, Veria, QA Swarm and an advisory review) found the bugs that the later commits fix. Each fix carries a regression test, which is most of the growth in this diff. Two claims in earlier review replies were wrong, and later commits correct them: a dead observed_listing flag that gated nothing, and a parity claim about @posthog/mcp that was the opposite of the truth.

The last code commit collapses duplication the review left behind. Four appenders become one, three adapter call sites become one, and the two registry adapters get the same tri-state ownership check as the low-level one.

Two choices are worth a reviewer's attention. Later-page shadowing inverts the old sticky-flag behavior, which the JS SDK also does, so a change here needs a matching change there. An ownership check that cannot answer gives the call to the host. That loses a PostHog event, but it never breaks a host tool.


Created with PostHog Desktop

The SDK advertises two virtual tools into tools/list: get_more_tools
(report_missing) and send_feedback (collect_feedback). A client
concatenates every page into one list, so each may appear on exactly
one page.

get_more_tools had no page gate at all, so a cursor-following client
saw it once per page. send_feedback went on the last page only, hiding
it from every client that never follows nextCursor. Both now go on the
first page -- the page every client reads -- matching @posthog/mcp.
An empty-string cursor is a valid opaque cursor, so it reads as a
continuation page rather than the first one.

get_more_tools also had no collision handling anywhere: no warning, no
shadow state, and unconditional interception on all four adapter
paths, so a host tool named get_more_tools was silently swallowed and
the agent got PostHog's canned reply. Both tools now share one
kind-keyed resolver, so this cannot drift again:

- A real tool owning the name on the first page wins: warn, skip
  injection, dispatch its calls normally.
- A real tool appearing only on a later page is shadowed, since page
  one cannot see page two. Warn when that page is served. @posthog/mcp
  behaves the same way.
- Ownership is also settled at call time, which covers a call reaching
  a process that never served a listing -- the ordinary multi-pod case.
  FastMCP and v2 MCPServer are asked via their tool registry; raw
  low-level servers have none, so the SDK asks the host's own
  tools/list handler, and only on a name match.
- Configuring both virtual tools with one name is detected and warned
  about; missing-capability wins, as every call path already assumed.

Warnings name the option that renames PostHog's tool and go to the
posthog.mcp stdlib logger as well as the logger option, so a
default-configured host actually sees them.

Two places matched the missing-capability name literally rather than
as configured: a renamed virtual tool picked up a conversation_id
argument the default-named one never got, and a real tool named
get_more_tools lost its context injection and its $mcp_intent.
mutate_tool_schema now takes is_sdk_virtual_tool from its caller
instead of guessing from the name.

PostHogMCP.prepare_tool_call honours original_tool for the
missing-capability tool as it already did for feedback.

Ports PostHog/posthog-js#4953 and PostHog/posthog-js#4967.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
@gesh
gesh requested a review from a team as a code owner September 15, 2026 12:26
@gesh
gesh removed the request for review from a team September 15, 2026 12:27
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-09-17 08:32:26 UTC
Duration: 256383ms

✅ 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 516ms
Endpoint And Method.Does Not Use Legacy Endpoints 511ms
Required Headers.Has Authorization Bearer Header 511ms
Required Headers.Has Content Type Json 511ms
Required Headers.Has Posthog Sdk Info Format 511ms
Required Headers.Has Posthog Attempt Header 510ms
Required Headers.Has Posthog Request Id 511ms
Required Headers.Has Posthog Request Timestamp 511ms
Required Headers.Has User Agent 511ms
Body Format.Body Has Created At And Batch 511ms
Body Format.No Api Key In Body 512ms
Body Format.No Sent At In Body 511ms
Event Format.Event Has Required Root Fields 510ms
Event Format.Event Uuid Is Valid 511ms
Event Format.Event Timestamp Is Rfc3339 510ms
Event Format.Distinct Id Is String 511ms
Event Format.Distinct Id At Root Not Properties 511ms
Event Format.Custom Properties Preserved 511ms
Event Format.Set Properties Preserved 510ms
Event Format.Set Once Properties Preserved 511ms
Event Format.Groups Properties Preserved 511ms
Event Format.Sdk Generates Uuid If Not Provided 511ms
Event Format.Event Has Required Root Fields Batch 514ms
Event Format.Event Uuid Is Valid Batch 513ms
Event Format.Event Timestamp Is Rfc3339 Batch 514ms
Event Format.Distinct Id Is String Batch 514ms
Event Format.Distinct Id At Root Not Properties Batch 513ms
Event Format.Custom Properties Preserved Batch 515ms
Event Format.Set Properties Preserved Batch 514ms
Event Format.Set Once Properties Preserved Batch 514ms
Event Format.Groups Properties Preserved Batch 514ms
Event Format.Sdk Generates Uuid If Not Provided Batch 514ms
Batch Behavior.Multiple Events In Single Batch 519ms
Batch Behavior.Batch Envelope Smoke 515ms
Batch Behavior.Flush With No Events Sends Nothing 507ms
Batch Behavior.Flush At Triggers Batch 1012ms
Batch Behavior.Created At Reflects Batch Creation Time 511ms
Deduplication.Generates Unique Uuids 518ms
Deduplication.Different Events Same Content Different Uuids 512ms
Deduplication.Preserves Uuid On Retry 6516ms
Deduplication.Preserves Timestamp On Retry 6521ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 6520ms
Deduplication.No Duplicate Events In Batch 519ms
Header Behavior On Retry.Attempt Header Starts At One 510ms
Header Behavior On Retry.Attempt Header Increments On Retry 13523ms
Header Behavior On Retry.Request Id Preserved On Retry 6520ms
Header Behavior On Retry.Different Requests Have Different Request Ids 3021ms
Header Behavior On Retry.Request Timestamp Changes On Retry 6520ms
Response Format Validation.Success Response Has Uuid Keyed Results 511ms
Response Format Validation.Success Response Has Ok For Each Event 515ms
Response Format Validation.Success No Retry After When All Ok 513ms
Response Format Validation.Success Retry After Present When Retry Events 1515ms
Response Format Validation.Success No Retry After When Drop Only 513ms
Response Format Validation.Response Echoes Request Id 511ms
Retry Behavior.Retries On 408 6519ms
Retry Behavior.Retries On 500 6517ms
Retry Behavior.Retries On 503 8523ms
Retry Behavior.Retries On 504 6517ms
Retry Behavior.Retryable Errors Have Retry After 3517ms
Retry Behavior.Respects Retry After On Retryable Error 11517ms
Retry Behavior.Does Not Retry On 400 2515ms
Retry Behavior.Does Not Retry On 401 2513ms
Retry Behavior.Does Not Retry On 402 2514ms
Retry Behavior.Does Not Retry On 413 2515ms
Retry Behavior.Does Not Retry On 415 2512ms
Retry Behavior.Non Retryable Errors Have No Retry After 2514ms
Retry Behavior.Implements Backoff 22523ms
Retry Behavior.Max Retries Respected 22537ms
Partial Batch Handling.Handles 200 Full Success 2514ms
Partial Batch Handling.Handles 200 With All Ok 3515ms
Partial Batch Handling.Does Not Retry Dropped Events 3517ms
Partial Batch Handling.Does Not Retry Limited Events 3516ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 6523ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 6523ms
Partial Batch Handling.Retries Only Retry Events From Partial 6523ms
Partial Batch Handling.Partial Retry Preserves Uuids 6521ms
Partial Batch Handling.Partial Retry Attempt Header Increments 6522ms
Partial Batch Handling.Partial Retry Request Id Preserved 6517ms
Partial Batch Handling.Respects Retry After On Partial 8521ms
Partial Batch Handling.Unknown Result Treated As Terminal 3513ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 3517ms
Compression.Sends Gzip Content Encoding 511ms
Compression.No Content Encoding When Disabled 511ms
Compression.Compressed Body Is Decompressible 510ms
Error Handling.Does Not Retry On Unknown 4Xx 2512ms
Event Options.Cookieless Mode Override 511ms
Event Options.Disable Skew Correction Override 511ms
Event Options.Process Person Profile Override 510ms
Event Options.Product Tour Id Override 510ms
Event Options.Unset Options Omitted 510ms
Event Options.Options Override In Batch 513ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 511ms
Geoip And Historical Migration.Historical Migration Set In Body 510ms
Geoip And Historical Migration.Historical Migration Absent By Default 510ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 12ms
Request Payload.Flags Request Uses V2 Query Param 9ms
Request Payload.Flags Request Hits Flags Path Not Decide 9ms
Request Payload.Flags Request Omits Authorization Header 8ms
Request Payload.Token In Flags Body Matches Init 9ms
Request Payload.Groups Round Trip 9ms
Request Payload.Groups Default To Empty Object 9ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 8ms
Request Payload.Disable Geoip Omitted Defaults To False 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 9ms
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 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller 9ms
Retry Behavior.Retries Flags On 502 313ms
Retry Behavior.Retries Flags On 504 313ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 511ms

@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (1)

  1. posthog/mcp/_instrumentation.py, line 571-583 (link)

    P1 Ownership resolves too late

    The lifecycle filters virtual-tool names through server-wide collision state before the adapters perform their request-local ownership checks. If caller A records a collision, caller B's virtual-tool call is treated as a normal host call and the live probe is skipped, so the call can be misrouted or fail as an unknown tool. Conversely, a real colliding tool called before any listing is dispatched normally, but its intent and conversation handling still use virtual-tool policy. Resolve ownership before constructing the lifecycle, then use that result consistently for interception, intent, and conversation handling.

    Knowledge Base Used: MCP framework instrumentation

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: posthog/mcp/_instrumentation.py
    Line: 571-583
    
    Comment:
    **Ownership resolves too late**
    
    The lifecycle filters virtual-tool names through server-wide collision state before the adapters perform their request-local ownership checks. If caller A records a collision, caller B's virtual-tool call is treated as a normal host call and the live probe is skipped, so the call can be misrouted or fail as an unknown tool. Conversely, a real colliding tool called before any listing is dispatched normally, but its intent and conversation handling still use virtual-tool policy. Resolve ownership before constructing the lifecycle, then use that result consistently for interception, intent, and conversation handling.
    
    **Knowledge Base Used:** [MCP framework instrumentation](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-python/-/docs/mcp-framework-instrumentation.md)
    
    ---
    
    For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Prompt To Fix All With AI
### Issue 1
posthog/mcp/_instrumentation.py:571-583
**Ownership resolves too late**

The lifecycle filters virtual-tool names through server-wide collision state before the adapters perform their request-local ownership checks. If caller A records a collision, caller B's virtual-tool call is treated as a normal host call and the live probe is skipped, so the call can be misrouted or fail as an unknown tool. Conversely, a real colliding tool called before any listing is dispatched normally, but its intent and conversation handling still use virtual-tool policy. Resolve ownership before constructing the lifecycle, then use that result consistently for interception, intent, and conversation handling.

### Issue 2
posthog/mcp/posthog_mcp.py:398-417
**Collisions lose context**

`prepare_tool_list` injects context before checking whether an application tool owns a virtual-tool name. `_is_virtual_tool_name` therefore mistakes a real `get_more_tools` or enabled `send_feedback` tool for an SDK descriptor and leaves its schema unchanged. The real tool wins dispatch, but the agent is not given the analytics `context` field, so its intent is lost. Determine ownership before schema mutation and skip context injection only for descriptors created by PostHog.

### Issue 3
posthog/mcp/_instrument_lowlevel.py:352-365
**Tool calls rerun listings**

The raw-server ownership probe invokes the host's `tools/list` handler again during a matching `tools/call`, using synthesized first-page parameters; the v2 path does the same with the tool-call context. If that handler is stateful, permission-sensitive, expensive, or has side effects, the probe can advance state, repeat work, or fail. A failure is treated as “not owned,” causing a real colliding tool to be intercepted instead of dispatched. Use an explicit ownership registry or callback rather than executing the listing handler from the call path.

---

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): inject virtual tools once, on ..." | Re-trigger Greptile

Comment thread posthog/mcp/posthog_mcp.py
Comment thread posthog/mcp/_instrument_lowlevel.py

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated advisory code review.

Comment thread posthog/mcp/_instrument_lowlevel.py Outdated
@marandaneto
marandaneto requested review from a team September 15, 2026 12:32
Review found two ways the SDK mistook its own virtual tool for a
host tool.

A host may return the SAME tools/list result object from every
request -- a module-level constant, or its own cache. The appends
mutated that object in place, so the next read of it showed PostHog's
virtual tool sitting in what looked like the host's catalogue. The SDK
reported a collision against itself, stopped intercepting, and handed
the agent "Unknown tool: send_feedback" instead of recording its
feedback. Appends now build a copy and the adapters serve that, so
every read of a listing is a faithful view of what the host wrote.
Reported by @marandaneto, with a reproduction.

On the PostHogMCP path, _is_virtual_tool_name matched on the name
alone, in both directions: a host's own tool called get_more_tools was
skipped for context injection and silently lost its $mcp_intent, while
a host re-preparing an already-prepared list had PostHog's descriptor
treated as a host collision -- warning about ourselves. Both now
compare against the descriptor the SDK would build for that name; the
description is ours and, unlike the schema, survives the
model-injection pass. Flagged by Greptile.

Also stop re-invoking the host's tools/list handler from the call path
once a listing has been observed: its collision state already carries
the answer. The probe now runs only while this process has served no
listing -- the multi-pod case it exists for -- so a stateful or
expensive handler is not re-entered on every virtual-tool call.
Flagged by Greptile.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
Comment thread posthog/mcp/_instrumentation.py Outdated
Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
@veria-ai

veria-ai Bot commented Sep 15, 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: 2 · PR risk: 0/10

Only the missing-capability name was matched literally; the feedback
name already resolved correctly, so a renamed send_feedback was never
affected.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6

@lucasheriques lucasheriques left a comment

Copy link
Copy Markdown
Contributor

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 — two inline findings plus a reply in the existing observed_listing thread. Verdict and evidence in the pinned summary comment.

Comment thread posthog/test/mcp/test_virtual_tools.py Outdated
Comment thread posthog/mcp/_instrumentation.py Outdated
@lucasheriques

Copy link
Copy Markdown
Contributor

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: ⚠️ REQUEST CHANGES (round 1 @ f07e28a)

The pagination and collision design (kind-keyed resolver, copy-on-append, first-page-only injection, custom names, same-name precedence) is sound and verified live on both SDK majors. Two claims about call-time ownership do not hold, and the test meant to cover one of them cannot fail. Both defects are silent: no crash and no swallowed call, but the analytics data this SDK exists to produce degrades.

Key findings

  • 🟠 HIGH — observed_listing is never set (_instrumentation.py:888). The probe re-runs the host's tools/list handler on every virtual-tool call on raw low-level servers, contradicting the docstring, README, commit 8b4b35b and the greptile reply. Behaviour matches JS; text does not. Decision needed: set the flag, or drop the flag and the claim.
  • 🟠 HIGH — real tool owning a virtual-tool name loses $mcp_intent and conversation id (_instrumentation.py:571). Call-time probes gate interception but never update virtual_tool_collisions, so conversation-id and intent resolution still treat the name as the virtual tool. Permanent on FastMCP/MCPServer until a listing is served.
  • 🟡 MEDIUM — vacuous regression test (test_virtual_tools.py:407). Counts on the registry entry while the probe calls the captured original function.

Convergence

Both HIGH findings were reached independently by the router (Sonnet) and by the escalation tier (Fable), each with its own reproduction. The router additionally reproduced both on mcp 2.2.0.

Verified clean

v2 handler params are validated PaginatedRequestParams | None models; append_virtual_tool preserves nextCursor on both majors and all four append helpers route through it; the new mutable sets are safe under FastMCP's worker-thread offload; PostHogMCP descriptor comparison handles re-prepared lists. Tests: 510 passed on mcp 1.30.0, 63 v2 tests passed on mcp 2.2.0.

Reviewer summaries

Reviewer Assessment
🧭 router Danger HIGH, confidence HIGH. Traced all four adapters on list and call paths; delegated the v2 wire shape, output-instructions bookkeeping and thread safety to a general lens, all clean.
🧪 fable (escalation) Independently reproduced the dead flag, the vacuous test, and the intent/conversation-id loss on raw low-level v1 and FastMCP v1.

Automated by QA Swarm — not a human review

`observed_listing` was declared and read but never assigned, so the
guard never activated. My reply on #962 claimed otherwise -- the claim
was wrong, not the code.

Keep the per-call behaviour, which is the correct one, and fix the
text instead. `virtual_tool_collisions` is per-server state rewritten
by whichever listing ran last, so a raw server serving different
catalogues to different callers would answer one caller from
another's listing. Asking per call cannot go stale that way, and
`@posthog/mcp` probes per call for the same reason. Gating on a
first-listing flag would also have falsified the README sentence that
the call-time checks are the reliable signal for per-caller tool sets.

test_listing_stops_the_call_path_reprobing_the_host could not fail:
the probe closes over the original handler at wrap time, so replacing
the `request_handlers` entry observed nothing. Replaced with a test
that counts inside the host's own handler and pins the actual cost --
one probe per call to a virtual-tool name, none for ordinary tool
traffic. Writing it turned up a fourth invocation that is the MCP
SDK's own `req is None` cache repopulation on a call to an unlisted
name, so the test excludes that rather than miscounting it as ours.

README now states the real cost and offers renaming as the way to
avoid the check.

Reported by veria-ai and QA Swarm on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 15, 2026
With enable_conversation_id on, get_more_tools and send_feedback now
advertise conversation_id, echo a minted handle back over prompt-back,
and stamp $mcp_conversation_id -- under any configured name.

They were exempt, in three places that had to agree: the schema pass
skipped the argument, resolve_conversation_id short-circuited on their
names, and the lifecycle passed None when preparing their session. So
a $mcp_feedback or $mcp_missing_capability event was filed under a
$session_id of its own. An agent's complaint about a tool landed in a
different session than the call it was complaining about, which is
most of the value of the report, and the fallback session emitted a
second spurious $mcp_initialize.

resolve_conversation_id loses its two tool-name parameters: with no
tool exempt there is nothing to compare against, so the exemption
cannot come back by accident.

The virtual tools still never get the injected `context` argument --
they state their intent through their own -- so `is_sdk_virtual_tool`
now gates only that.

Two tests are inverted on purpose, with the reasoning in their
docstrings: test_feedback_never_mints_conversation_id becomes
test_feedback_joins_the_conversation, and the renamed-tool schema case
now expects the argument to track the option.

Stacked on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
Comment thread posthog/mcp/_instrumentation.py Outdated

@lucasheriques lucasheriques left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving to unblock: fd42c1e closes the flag and the test. One thread is still open (_instrumentation.py:571, intent and conversation id for a real tool owning a virtual-tool name; Veria's newer comment there shares the root cause). Please either fix it or trim the changeset sentence that claims a real get_more_tools keeps its $mcp_intent before merging, since that only holds after a first-page listing on the same instance. Happy with a follow-up PR for the code change if you'd rather ship this one now.

gesh added a commit that referenced this pull request Sep 16, 2026
Capture intent for tools the host owns; skip it only for PostHog's own.
The code asked that question two different ways and they disagreed.

Interception asked a per-request probe, which was right. Intent and the
conversation exemption asked `virtual_tool_collisions`, which only a
served tools/list writes. On a process that had served none -- the
ordinary multi-pod case the probe exists for -- a host tool named
`get_more_tools` dispatched correctly but its $mcp_tool_call carried
`$mcp_intent=None`. Permanent on FastMCP and v2 MCPServer; on raw
low-level it self-healed after one call, because the MCP SDK's own
`req is None` cache pass happens to refresh the state during dispatch.

The intent guard turns out to be dead code for its stated purpose:
PostHog's virtual tools are intercepted before dispatch and captured by
record_missing_capability / record_feedback, so they never reach
record_tool_call. Verified by spying on it. Everything that gets there
is host-dispatched by construction, so the guard could only ever fire
on a host tool sharing the name. Removed.

The same applies to the conversation exemption: the virtual tools'
capture paths discard the resolved handle, so exempting by name only
ever cost the host's tool its handle.

start_tool_call_lifecycle now reads `enabled_virtual_tool_names`
instead of `injectable_`, so the call path stops reading listing state
altogether. That also closes the cross-client report: the collision set
is per-server and rewritten by whichever listing ran last, so reading
it on the call path let one caller's catalogue decide another caller's
call.

Note on the reports: the missing conversation id they also cite is not
name-specific -- an ordinary tool shows the same in that harness, so it
is not a regression here. The new tests assert parity with an ordinary
tool instead, which is the rule being fixed.

The changeset claimed a real `get_more_tools` "keeps its $mcp_intent",
which was only true after a listing on the same instance. Corrected.

Reported by QA Swarm and veria-ai on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
With enable_conversation_id on, get_more_tools and send_feedback now
advertise conversation_id, echo a minted handle back over prompt-back,
and stamp $mcp_conversation_id -- under any configured name.

They were exempt, in three places that had to agree: the schema pass
skipped the argument, resolve_conversation_id short-circuited on their
names, and the lifecycle passed None when preparing their session. So
a $mcp_feedback or $mcp_missing_capability event was filed under a
$session_id of its own. An agent's complaint about a tool landed in a
different session than the call it was complaining about, which is
most of the value of the report, and the fallback session emitted a
second spurious $mcp_initialize.

resolve_conversation_id loses its two tool-name parameters: with no
tool exempt there is nothing to compare against, so the exemption
cannot come back by accident.

The virtual tools still never get the injected `context` argument --
they state their intent through their own -- so `is_sdk_virtual_tool`
now gates only that.

Two tests are inverted on purpose, with the reasoning in their
docstrings: test_feedback_never_mints_conversation_id becomes
test_feedback_joins_the_conversation, and the renamed-tool schema case
now expects the argument to track the option.

Stacked on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
A real tool of yours named `get_more_tools` now gets `context` injected
unconditionally, but whether its value is captured as `$mcp_intent`
still depends on this instance having served a tools/list. That half
belongs with the attribution work in the follow-up PR, so don't claim
it here.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
@gesh
gesh force-pushed the posthog/mcp-virtual-tool-first-page branch from 8daec48 to 3aac206 Compare September 16, 2026 09:29
gesh added a commit that referenced this pull request Sep 16, 2026
Capture intent for tools the host owns; skip it only for PostHog's own.
The code asked that question two different ways and they disagreed.

Interception asked a per-request probe, which was right. Intent and the
conversation exemption asked `virtual_tool_collisions`, which only a
served tools/list writes. On a process that had served none -- the
ordinary multi-pod case the probe exists for -- a host tool named
`get_more_tools` dispatched correctly but its $mcp_tool_call carried
`$mcp_intent=None`. Permanent on FastMCP and v2 MCPServer; on raw
low-level it self-healed after one call, because the MCP SDK's own
`req is None` cache pass happens to refresh the state during dispatch.

The intent guard turns out to be dead code for its stated purpose:
PostHog's virtual tools are intercepted before dispatch and captured by
record_missing_capability / record_feedback, so they never reach
record_tool_call. Verified by spying on it. Everything that gets there
is host-dispatched by construction, so the guard could only ever fire
on a host tool sharing the name. Removed.

The same applies to the conversation exemption: the virtual tools'
capture paths discard the resolved handle, so exempting by name only
ever cost the host's tool its handle.

start_tool_call_lifecycle now reads `enabled_virtual_tool_names`
instead of `injectable_`, so the call path stops reading listing state
altogether. That also closes the cross-client report: the collision set
is per-server and rewritten by whichever listing ran last, so reading
it on the call path let one caller's catalogue decide another caller's
call.

Note on the reports: the missing conversation id they also cite is not
name-specific -- an ordinary tool shows the same in that harness, so it
is not a regression here. The new tests assert parity with an ordinary
tool instead, which is the rule being fixed.

The changeset claimed a real `get_more_tools` "keeps its $mcp_intent",
which was only true after a listing on the same instance. Corrected.

Reported by QA Swarm and veria-ai on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
With enable_conversation_id on, get_more_tools and send_feedback now
advertise conversation_id, echo a minted handle back over prompt-back,
and stamp $mcp_conversation_id -- under any configured name.

They were exempt, in three places that had to agree: the schema pass
skipped the argument, resolve_conversation_id short-circuited on their
names, and the lifecycle passed None when preparing their session. So
a $mcp_feedback or $mcp_missing_capability event was filed under a
$session_id of its own. An agent's complaint about a tool landed in a
different session than the call it was complaining about, which is
most of the value of the report, and the fallback session emitted a
second spurious $mcp_initialize.

resolve_conversation_id loses its two tool-name parameters: with no
tool exempt there is nothing to compare against, so the exemption
cannot come back by accident.

The virtual tools still never get the injected `context` argument --
they state their intent through their own -- so `is_sdk_virtual_tool`
now gates only that.

Two tests are inverted on purpose, with the reasoning in their
docstrings: test_feedback_never_mints_conversation_id becomes
test_feedback_joins_the_conversation, and the renamed-tool schema case
now expects the argument to track the option.

Stacked on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
The per-request ownership fix landed here rather than in #962, so its
user-facing effects belong in this changeset, not that one.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
The call-time ownership check has three outcomes, not two: the host
owns the name, the host does not, or the question could not be asked --
a raw low-level server whose own tools/list handler is failing. The
third collapsed into "does not own", so the SDK intercepted.

That is the wrong direction. Guessing toward interception breaks the
host's tool, silently, for as long as their handler stays unwell, to
protect an analytics affordance. Guessing toward delegation costs one
visible failed call to a tool of PostHog's, which nothing depends on.
An analytics SDK does not get to break the product it measures, which
is the rule the rest of this package already follows.

`raw_listing_owns_tool_name` now returns Optional[bool], and the four
call sites intercept only on a definite False. Registry lookups are
unchanged: "not found" there is an answer, not a failure. The host gets
a warning naming the tool and the reason.

Also removes a divergence from @posthog/mcp, which gates on
`isToolAdvertised(...) === false` and delegates for the same reason. My
earlier reply on this PR claimed we already matched it; we did not.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
Capture intent for tools the host owns; skip it only for PostHog's own.
The code asked that question two different ways and they disagreed.

Interception asked a per-request probe, which was right. Intent and the
conversation exemption asked `virtual_tool_collisions`, which only a
served tools/list writes. On a process that had served none -- the
ordinary multi-pod case the probe exists for -- a host tool named
`get_more_tools` dispatched correctly but its $mcp_tool_call carried
`$mcp_intent=None`. Permanent on FastMCP and v2 MCPServer; on raw
low-level it self-healed after one call, because the MCP SDK's own
`req is None` cache pass happens to refresh the state during dispatch.

The intent guard turns out to be dead code for its stated purpose:
PostHog's virtual tools are intercepted before dispatch and captured by
record_missing_capability / record_feedback, so they never reach
record_tool_call. Verified by spying on it. Everything that gets there
is host-dispatched by construction, so the guard could only ever fire
on a host tool sharing the name. Removed.

The same applies to the conversation exemption: the virtual tools'
capture paths discard the resolved handle, so exempting by name only
ever cost the host's tool its handle.

start_tool_call_lifecycle now reads `enabled_virtual_tool_names`
instead of `injectable_`, so the call path stops reading listing state
altogether. That also closes the cross-client report: the collision set
is per-server and rewritten by whichever listing ran last, so reading
it on the call path let one caller's catalogue decide another caller's
call.

Note on the reports: the missing conversation id they also cite is not
name-specific -- an ordinary tool shows the same in that harness, so it
is not a regression here. The new tests assert parity with an ordinary
tool instead, which is the rule being fixed.

The changeset claimed a real `get_more_tools` "keeps its $mcp_intent",
which was only true after a listing on the same instance. Corrected.

Reported by QA Swarm and veria-ai on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
With enable_conversation_id on, get_more_tools and send_feedback now
advertise conversation_id, echo a minted handle back over prompt-back,
and stamp $mcp_conversation_id -- under any configured name.

They were exempt, in three places that had to agree: the schema pass
skipped the argument, resolve_conversation_id short-circuited on their
names, and the lifecycle passed None when preparing their session. So
a $mcp_feedback or $mcp_missing_capability event was filed under a
$session_id of its own. An agent's complaint about a tool landed in a
different session than the call it was complaining about, which is
most of the value of the report, and the fallback session emitted a
second spurious $mcp_initialize.

resolve_conversation_id loses its two tool-name parameters: with no
tool exempt there is nothing to compare against, so the exemption
cannot come back by accident.

The virtual tools still never get the injected `context` argument --
they state their intent through their own -- so `is_sdk_virtual_tool`
now gates only that.

Two tests are inverted on purpose, with the reasoning in their
docstrings: test_feedback_never_mints_conversation_id becomes
test_feedback_joins_the_conversation, and the renamed-tool schema case
now expects the argument to track the option.

Stacked on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
The per-request ownership fix landed here rather than in #962, so its
user-facing effects belong in this changeset, not that one.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
The changeset listed five bullets, two of them restating internals.
Cut to the three things a host upgrading would notice, plus the
listed_tool_names shift for anyone charting it.

The README section repeated the warning texts the code already emits
and the option snippet twice. Cut to the rules and their limits.

Also one real inconsistency in the code: a page's tool names were
gathered two ways, once through a helper and once inline, and the
helper's name shadowed the unrelated $mcp_listed_tool_names event
field. Now one helper, named advertised_tool_names.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
Capture intent for tools the host owns; skip it only for PostHog's own.
The code asked that question two different ways and they disagreed.

Interception asked a per-request probe, which was right. Intent and the
conversation exemption asked `virtual_tool_collisions`, which only a
served tools/list writes. On a process that had served none -- the
ordinary multi-pod case the probe exists for -- a host tool named
`get_more_tools` dispatched correctly but its $mcp_tool_call carried
`$mcp_intent=None`. Permanent on FastMCP and v2 MCPServer; on raw
low-level it self-healed after one call, because the MCP SDK's own
`req is None` cache pass happens to refresh the state during dispatch.

The intent guard turns out to be dead code for its stated purpose:
PostHog's virtual tools are intercepted before dispatch and captured by
record_missing_capability / record_feedback, so they never reach
record_tool_call. Verified by spying on it. Everything that gets there
is host-dispatched by construction, so the guard could only ever fire
on a host tool sharing the name. Removed.

The same applies to the conversation exemption: the virtual tools'
capture paths discard the resolved handle, so exempting by name only
ever cost the host's tool its handle.

start_tool_call_lifecycle now reads `enabled_virtual_tool_names`
instead of `injectable_`, so the call path stops reading listing state
altogether. That also closes the cross-client report: the collision set
is per-server and rewritten by whichever listing ran last, so reading
it on the call path let one caller's catalogue decide another caller's
call.

Note on the reports: the missing conversation id they also cite is not
name-specific -- an ordinary tool shows the same in that harness, so it
is not a regression here. The new tests assert parity with an ordinary
tool instead, which is the rule being fixed.

The changeset claimed a real `get_more_tools` "keeps its $mcp_intent",
which was only true after a listing on the same instance. Corrected.

Reported by QA Swarm and veria-ai on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
With enable_conversation_id on, get_more_tools and send_feedback now
advertise conversation_id, echo a minted handle back over prompt-back,
and stamp $mcp_conversation_id -- under any configured name.

They were exempt, in three places that had to agree: the schema pass
skipped the argument, resolve_conversation_id short-circuited on their
names, and the lifecycle passed None when preparing their session. So
a $mcp_feedback or $mcp_missing_capability event was filed under a
$session_id of its own. An agent's complaint about a tool landed in a
different session than the call it was complaining about, which is
most of the value of the report, and the fallback session emitted a
second spurious $mcp_initialize.

resolve_conversation_id loses its two tool-name parameters: with no
tool exempt there is nothing to compare against, so the exemption
cannot come back by accident.

The virtual tools still never get the injected `context` argument --
they state their intent through their own -- so `is_sdk_virtual_tool`
now gates only that.

Two tests are inverted on purpose, with the reasoning in their
docstrings: test_feedback_never_mints_conversation_id becomes
test_feedback_joins_the_conversation, and the renamed-tool schema case
now expects the argument to track the option.

Stacked on #962.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
gesh added a commit that referenced this pull request Sep 16, 2026
The per-request ownership fix landed here rather than in #962, so its
user-facing effects belong in this changeset, not that one.

Generated-By: PostHog Desktop
Task-Id: 989fd424-fd24-4ae4-8682-548b87f761f6
The call-time ownership probe added in this branch runs the host's own
tools/list handler. When that handler is registered through the SDK's
`@server.list_tools()` decorator, mcp 1.x rebuilds `Server._tool_cache`
from the tools it returns on every invocation - and the probe, unlike the
wrapper's `req is None` branch, never re-ran schema injection afterwards.

That cache is what the SDK validates real tool arguments against, so one
ordinary `get_more_tools` call left every later call to a real tool
rejected with "Additional properties are not allowed ('context' was
unexpected)" for sending the argument we advertised, until the next
client-facing listing healed it. An analytics SDK breaking the host's
tools is the failure this branch exists to prevent.

The probe also closed over the handler captured at instrument() time, so
a host registering tools/list afterwards had ownership answered from a
catalogue no client ever sees - a confident wrong answer that swallowed
their real tool. `@posthog/mcp` re-captures the handler for this reason;
read the current one instead and report the question as unanswerable when
it is no longer ours, so the call is delegated.

Both paths are covered by tests that fail without the fix.

Generated-By: PostHog Desktop
Task-Id: f66265f3-22a8-4eb5-9bf1-bf174c1c0c65
…d state

`virtual_tool_collisions` existed to carry a listing's answer to the call
path. It was not needed for either job.

At listing time the page's own tools are already in hand, so the decision
is local: on a first page inject the name or warn that a real tool has
it, on a later page warn that ours already shadows theirs. Four branches,
no state - the shape `@posthog/mcp` uses.

At call time every site already gates on a stronger signal than the set
ever was: the tool registry on FastMCP and v2 MCPServer, the listing
probe on raw low-level servers. Those answer correctly in a process that
never served a listing, which is exactly where the set was empty and so
silently wrong - the multi-pod case it was meant to cover.

Names now resolve through `enabled_virtual_tool_names` everywhere, which
keeps the fix that matters: with `report_missing` off, a real tool called
`get_more_tools` is an ordinary tool and keeps its `context` captured as
intent.

Attribution for a real tool that *collides* with a virtual tool's name -
`$mcp_intent` and `$mcp_conversation_id` - is dropped here and belongs to
the stacked follow-up, which can read it off the ownership answer the
call path already computes rather than from state that goes stale.

Generated-By: PostHog Desktop
Task-Id: f66265f3-22a8-4eb5-9bf1-bf174c1c0c65
Three fixes from review of the two commits before this one.

`_name_owned_by_real_tool`'s standalone-fastmcp branch caught every
exception from `get_tool` and answered False - "no real tool owns this
name" - on the assumption that an unknown name raises. In fastmcp 3.x it
does not: `get_tool` returns None for an unknown name and raises only
when the lookup itself fails, and its provider chain can reach a mounted
or proxied upstream over the network. So a connection blip was read as
"the name is free", and a call to a host tool that shares a virtual
tool's name was swallowed and answered with PostHog's canned reply and
isError=False - a fabricated success over a tool that never ran.

Until the commit before this one a listing-derived collision flag made
interception impossible for a name the host owned, which masked this.
Removing that flag left the lookup deciding alone. Catch fastmcp's
not-found and disabled errors as a real answer; delegate on anything
else, which both call sites already do for None.

The continuation-page "shadowed" warning no longer fires for a name a
first page already blocked. Nothing of ours is advertised in that case
and the host's tool runs, so telling them "the real tool will not run"
sent them chasing a bug that is not there.

The foreign-handler guard now covers a removed handler as well as a
replaced one, and says so once. The virtual tools stay advertised when
another layer wraps tools/list - a chained wrapper still runs our
injection - but nothing behind them is ever intercepted, and a silent
stop is invisible in the captured data. The README already promised this
was logged.

Also corrects a `raw_listing_owns_tool_name` docstring that described an
approach never taken (reading `Server._tool_cache`), which would have
invited removing the re-injection that repairs it, and sweeps five
comments describing the deleted collision state.

Generated-By: PostHog Desktop
Task-Id: f66265f3-22a8-4eb5-9bf1-bf174c1c0c65
Three review fixes.

The continuation-page "shadowed" warning skipped a name a first page had
blocked, but not one the other virtual tool had won under the duplicate
name check. Configure both tools to one name and a host tool by that name
on a later page drew two warnings, one of them for a tool that was never
advertised. Skip any kind that did not make it onto the first page,
whichever way it lost.

The foreign-handler warning covers a removed handler as well as a
replaced one, so it says so, and both branches now have a test - the
removed one had none, which is why the wording went unnoticed.

Also corrects the comment on the ownership lookup's except branch. It
claimed a raised exception meant a mounted or proxied upstream had
blipped. It cannot: fastmcp gathers its providers with
`return_exceptions=True` and drops the failures, so an upstream blip
reads back as a plain None, indistinguishable from "no such tool", and
resolves to False without reaching that branch. What does reach it is the
visibility, transform and auth work layered on top of the providers. The
provider case is unchanged from before this SDK grew an ownership check -
on the missing-capability path it is strictly better, since that path
took no ownership check at all.

Generated-By: PostHog Desktop
Task-Id: f66265f3-22a8-4eb5-9bf1-bf174c1c0c65
…nt tool

A `FastMCP` is an `AggregateProvider`, which gathers its providers with
`return_exceptions=True` and drops the failures, so an unreachable mounted
or proxied sub-server reads back as a plain None from `get_tool` -
indistinguishable from "no such tool". If that sub-server owned a real
tool by a virtual tool's name, we treat the name as free and intercept.

Worth writing down because the obvious fix does not work: the same
failure is dropped from `list_tools` too, so a listing fallback returns
the same blind answer, and fastmcp 3.x exposes no error strategy to opt
out of. A later reader would otherwise rediscover this and add a probe
that cannot help.

Narrower than it first reads, which the note also records: during the
outage the host's tool is missing from tools/list as well, so it could
not have been dispatched either way. Only a provider that recovers
between the check and dispatch loses a call that would have worked.

Behaviour unchanged.

Generated-By: PostHog Desktop
Task-Id: f66265f3-22a8-4eb5-9bf1-bf174c1c0c65
The kind-keyed resolver put per-tool policy in one place, but the layer
below it stayed four copies. `append_get_more_tools`, `append_send_feedback`
and their two v2 twins differed only by descriptor builder and
`inputSchema` vs `input_schema`, and the block that called them was
repeated verbatim in all three adapters.

- One `append_virtual_tool_by_kind` behind one `virtual_tool_descriptor`
  switch, applied through one `apply_virtual_tool_injection`. The
  missing-capability-then-feedback order the `duplicate` rule depends on is
  now stated once instead of implied in three places.
- Both raw probes use `advertised_tool_names` rather than a third copy of
  the same comprehension.
- `VirtualToolInjection` wrapped a dict and two `.get`s that nothing reads
  any more, so the resolver returns the dict.
- Dead branches out: the `missing_name or name` fallback the
  `is_missing_capability` property already rules out, the unreachable
  `options is None` guard in the feedback appenders, and an `event` looked
  up before a branch that never used it.

One behaviour change. `_name_owned_by_real_tool` on the low-level adapter
was tri-state — a lookup that raises means "could not answer", and the call
is delegated — but the FastMCP and v2 registry paths still read every
failure as "the name is free" and swallowed the host's own tool. All three
now share the contract, with a test per adapter that makes the registry
raise and asserts the real tool runs. Both fail without the change.

Prose: the PR was adding roughly 0.7 lines of comment per line of code
against ~0.2 in the same files. Cut the narrative, the `@posthog/mcp`
commentary and the sentences that had reached three copies; kept the
`_tool_cache` re-injection reason, the late-bound handler lookup, the
empty-string cursor rule, the JS-parity warning and the provider note,
which was stated twice and is now stated once.

Tests: `_make_paged_lowlevel`, `_list_page`, `_call_request` and
`_ECHO_TOOL` were defined in both virtual-tool test files; they move to
`_helpers_lowlevel`, kept out of `_helpers` because that one loads under
both SDK majors. Dropped one resolver test subsumed by its neighbours.

515 passed on mcp 1.x, 440 passed / 19 skipped on 2.x, mypy and the public
API snapshot clean.

Generated-By: PostHog Desktop
Task-Id: b86d25ed-d899-4e29-a52f-cd0ab29ae38d
Four bullets down to two sentences, matching the one-line house style of
the changesets around it.

Drops the claim that a real tool of yours sharing a virtual tool's name
"now wins". That holds on the first page; on a later page this PR
deliberately inverts it, so as a release note it would tell a host in that
shape their tool is safe when it is not. The README and the PR body carry
the full rule. What a reader needs from a changelog is that the names can
collide and that PostHog says so, which the warning sentence gives them.

Also drops the reused-result-object fix and the listed_tool_names note:
both are repairs to behaviour this release introduces, invisible to anyone
upgrading from the last one.

Generated-By: PostHog Desktop
Task-Id: b86d25ed-d899-4e29-a52f-cd0ab29ae38d
`instrument()` already handled a host that configures
`missing_capability_tool_name` and `collect_feedback.tool_name` to the
same string: it drops the feedback tool, keeps missing-capability
precedence, and warns "duplicate". The `PostHogMCP` dispatcher did
neither.

`prepare_tool_list` fell through to its "name already taken" branch, so
the feedback descriptor went unadvertised with no warning.
`prepare_tool_call` then set `is_feedback` and `is_missing_capability`
both true, and the README's dispatch snippet tests `is_feedback` first,
so every missing-capability call became a bogus $mcp_feedback capture --
the inverse of the precedence the other path sets on purpose.

Also folds four copies of the ownership-probe warning into one helper,
and names the three collision variants, so a typo is a type error rather
than silently picking the "blocked" wording.

Generated-By: PostHog Desktop
Task-Id: 7047b34a-b562-40ab-ae65-8b0f24700e57
@gesh
gesh requested a review from marandaneto September 17, 2026 08:38

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated advisory code review.

missing_name = resolve_missing_capability_tool_name(data.options)
enabled = enabled_virtual_tool_names(data)
missing_name = enabled.get(VIRTUAL_TOOL_MISSING_CAPABILITY)
feedback_name = enabled.get(VIRTUAL_TOOL_FEEDBACK)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

blocking: Preserve conversation attribution for real feedback tools — With collect_feedback=True and enable_conversation_id=True, a real application tool named send_feedback still runs after listing but now loses its conversation ID and prompt-back. feedback_name no longer excludes host-owned tools before resolve_conversation_id() runs; the later ownership check preserves dispatch without correcting attribution. Unlike the get_more_tools attribution work deferred to #963, this send_feedback case already worked on the merge base. Reproduction: reproduced — uv run --extra dev --extra test pytest posthog/test/mcp/test_pr962_regression.py -q --timeout=30, using FastMCP call_tool(convert_result=True) after tools/list, fails on a091101 with missing $mcp_conversation_id and passes on merge base 794354c.

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.

The feedback feature is still in progress. Working on a follow-up PR to add a proper conversation ID to the virtual tools.

@marandaneto

Copy link
Copy Markdown
Member

Two non-blocking product decisions worth explicitly confirming before merging:

  1. A real tool on a later page can become unreachable. For example, the host advertises search on page 1 and its own send_feedback on page 2. PostHog adds its virtual send_feedback to page 1 because it cannot see page 2 yet. The client then sees two tools with the same name. On the raw low-level path, the call-time ownership check also inspects only the first page, so calls to that name reach PostHog rather than the host's implementation. A warning is emitted when the later-page collision is seen, and renaming PostHog's tool avoids the conflict. This is documented in the PR, but is making a host tool unreachable an intentional and acceptable product tradeoff for first-page discovery and JS parity?

  2. Every virtual-tool call can rerun the host's listing work. FastMCP/MCPServer can check ownership through their registry. A raw low-level server instead invokes the host's original tools/list handler once per call to an enabled virtual-tool name, checking its first page before deciding whether to intercept or delegate. Ordinary tool calls do not incur this check. This is not necessarily another HTTP request, but if the listing handler queries a database or remote service, that work adds latency/load to every such call. The benefit is fresh, caller-specific ownership rather than trusting a previous listing. Is that cost acceptable for the intended integrations?

These are product/behavior callouts, not additional blocking findings. Please explicitly confirm whether both behaviors are intended; if not, the desired discovery, collision, and ownership-check behavior should be agreed before merge.

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

left a last comment and 2 product questions, otherwise lgtm

@gesh

gesh commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

Two non-blocking product decisions worth explicitly confirming before merging:

  1. A real tool on a later page can become unreachable. For example, the host advertises search on page 1 and its own send_feedback on page 2. PostHog adds its virtual send_feedback to page 1 because it cannot see page 2 yet. The client then sees two tools with the same name. On the raw low-level path, the call-time ownership check also inspects only the first page, so calls to that name reach PostHog rather than the host's implementation. A warning is emitted when the later-page collision is seen, and renaming PostHog's tool avoids the conflict. This is documented in the PR, but is making a host tool unreachable an intentional and acceptable product tradeoff for first-page discovery and JS parity?

Intentional. Page one cannot see page two, so any injection decision there is made without knowledge of later pages, and each alternative is worse: injecting on the last page is the bug this PR fixes (clients that ignore nextCursor never saw send_feedback), walking every page before deciding turns one listing into N against a handler that may hit a database, and deferring injection until the collision is visible is per-server state that lets one caller's listing decide another caller's dispatch.
TLDR: We simply want to warn when there's conflict, allowing the users to configure our virtual tool names to prevent conflicts.

  1. Every virtual-tool call can rerun the host's listing work. FastMCP/MCPServer can check ownership through their registry. A raw low-level server instead invokes the host's original tools/list handler once per call to an enabled virtual-tool name, checking its first page before deciding whether to intercept or delegate. Ordinary tool calls do not incur this check. This is not necessarily another HTTP request, but if the listing handler queries a database or remote service, that work adds latency/load to every such call. The benefit is fresh, caller-specific ownership rather than trusting a previous listing. Is that cost acceptable for the intended integrations?

These are product/behavior callouts, not additional blocking findings. Please explicitly confirm whether both behaviors are intended; if not, the desired discovery, collision, and ownership-check behavior should be agreed before merge.

Only raw low-level servers pay it, only on calls to an enabled virtual-tool name, and only for the first page — the alternative is trusting a previous or another caller's listing, which is the stateful cross-client bug this PR removes.

@gesh
gesh merged commit ed02588 into main Sep 17, 2026
43 checks passed
@gesh
gesh deleted the posthog/mcp-virtual-tool-first-page branch September 17, 2026 09:34
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.

3 participants