|
12 | 12 | PostHogMcpStatelessSessionMiddleware, |
13 | 13 | get_mcp_session, |
14 | 14 | ) |
| 15 | +from posthog.mcp._instrumentation import prepare_request |
| 16 | +from posthog.mcp.logger import set_logger |
15 | 17 | from posthog.mcp.session import new_session_id, resolve_session_id |
16 | 18 | from posthog.mcp.session_token import ( |
17 | 19 | MCP_SESSION_HEADER, |
@@ -540,3 +542,115 @@ def ping() -> str: |
540 | 542 | assert payload is not None, "instrument() did not auto-wire the mint" |
541 | 543 | assert payload.client_name == "Cursor" |
542 | 544 | assert payload.client_version == "0.42" |
| 545 | + |
| 546 | + |
| 547 | +# --- loud diagnostics for the silent "middleware never attached" failure ----- |
| 548 | + |
| 549 | + |
| 550 | +def _capture_logs(): |
| 551 | + """Route the SDK logger into a list, restoring the previous sink after.""" |
| 552 | + logs: list[str] = [] |
| 553 | + set_logger(logs.append) |
| 554 | + return logs |
| 555 | + |
| 556 | + |
| 557 | +async def test_prepare_request_warns_once_on_sessionless_http_request(): |
| 558 | + """An HTTP request that resolves to a per-process `generated` session (no token, |
| 559 | + no Mcp-Session-Id) is the fingerprint of a stateless server whose mint middleware |
| 560 | + never attached. That used to be silent; it must now warn -- but only once, so the |
| 561 | + log isn't flooded on every subsequent request.""" |
| 562 | + logs = _capture_logs() |
| 563 | + try: |
| 564 | + data = _data() |
| 565 | + for _ in range(3): |
| 566 | + await prepare_request( |
| 567 | + data, |
| 568 | + mcp_session_id=None, |
| 569 | + client_name=None, |
| 570 | + client_version=None, |
| 571 | + request={"method": "tools/call", "params": {}}, |
| 572 | + extra={}, |
| 573 | + token=None, |
| 574 | + http_request=True, |
| 575 | + ) |
| 576 | + finally: |
| 577 | + set_logger(None) |
| 578 | + |
| 579 | + warnings = [m for m in logs if "no session id" in m] |
| 580 | + assert len(warnings) == 1 |
| 581 | + assert "add_middleware(PostHogMcpStatelessSessionMiddleware)" in warnings[0] |
| 582 | + assert data.warned_no_stateless_session is True |
| 583 | + |
| 584 | + |
| 585 | +async def test_prepare_request_does_not_warn_for_stdio(): |
| 586 | + """stdio has no HTTP request, so a generated per-process session is correct -- |
| 587 | + never warn there (that would be noise on the common local-dev path).""" |
| 588 | + logs = _capture_logs() |
| 589 | + try: |
| 590 | + data = _data() |
| 591 | + await prepare_request( |
| 592 | + data, |
| 593 | + mcp_session_id=None, |
| 594 | + client_name=None, |
| 595 | + client_version=None, |
| 596 | + request={"method": "tools/call", "params": {}}, |
| 597 | + extra={}, |
| 598 | + token=None, |
| 599 | + http_request=False, |
| 600 | + ) |
| 601 | + finally: |
| 602 | + set_logger(None) |
| 603 | + |
| 604 | + assert not [m for m in logs if "no session id" in m] |
| 605 | + assert data.warned_no_stateless_session is False |
| 606 | + |
| 607 | + |
| 608 | +async def test_prepare_request_does_not_warn_when_token_present(): |
| 609 | + """A correctly-wired stateless server replays our token, so the session resolves |
| 610 | + from it -- no warning even though the request came over HTTP.""" |
| 611 | + logs = _capture_logs() |
| 612 | + try: |
| 613 | + data = _data() |
| 614 | + token = decode_session_id( |
| 615 | + encode_session_id(SessionTokenPayload(session_id="ses_ok")) |
| 616 | + ) |
| 617 | + await prepare_request( |
| 618 | + data, |
| 619 | + mcp_session_id=None, |
| 620 | + client_name=None, |
| 621 | + client_version=None, |
| 622 | + request={"method": "tools/call", "params": {}}, |
| 623 | + extra={}, |
| 624 | + token=token, |
| 625 | + http_request=True, |
| 626 | + ) |
| 627 | + finally: |
| 628 | + set_logger(None) |
| 629 | + |
| 630 | + assert not [m for m in logs if "no session id" in m] |
| 631 | + |
| 632 | + |
| 633 | +def test_autowire_warns_when_app_built_before_instrument(): |
| 634 | + """The ordering trap: building streamable_http_app() before instrument() leaves the |
| 635 | + live app without our middleware, and wrapping the factory afterward can't retrofit |
| 636 | + it. instrument() must warn instead of failing silently.""" |
| 637 | + pytest.importorskip("starlette.testclient") |
| 638 | + from mcp.server.fastmcp import FastMCP |
| 639 | + |
| 640 | + from posthog.mcp import instrument |
| 641 | + |
| 642 | + class _Sink: |
| 643 | + def capture(self, *_: object, **__: object) -> None: |
| 644 | + pass |
| 645 | + |
| 646 | + srv = FastMCP("posthog-ordering-trap", stateless_http=True, json_response=True) |
| 647 | + |
| 648 | + # Build the app BEFORE instrument() -- the customer's failure mode. |
| 649 | + srv.streamable_http_app() |
| 650 | + |
| 651 | + logs: list[str] = [] |
| 652 | + instrument(srv, _Sink(), MCPAnalyticsOptions(logger=logs.append)) |
| 653 | + |
| 654 | + assert any( |
| 655 | + "streamable_http_app() was called before instrument()" in m for m in logs |
| 656 | + ) |
0 commit comments