"""Flat per-tenant tool re-export for front_door topology mode (issue #232).

In front_door mode, external agents see ONLY flat backend tool names (e.g.
``read_item``) instead of the hangar_* meta-API.  This module wires the
per-request-filtered tools/list and flat call dispatch onto the FastMCP
server by re-registering the lowlevel handlers after the default handlers
are set up.

SDK seam used
-------------
FastMCP's ``_setup_handlers()`` (called in ``__init__``) registers
``self.list_tools`` and ``self.call_tool`` on the underlying
``MCPServer._mcp_server`` via the decorators exposed as
``mcp._mcp_server.list_tools()`` and ``mcp._mcp_server.call_tool()``.
These decorators replace ``request_handlers[ListToolsRequest]`` and
``request_handlers[CallToolRequest]`` with new closures and update the
``_tool_cache`` on each list call.  Re-calling those decorators with our own
async functions after construction simply replaces the handlers in the dict,
giving us full per-request control without any private-API subclassing.

See:
  .venv/…/mcp/server/lowlevel/server.py  list_tools() → line 434
                                           call_tool()  → line 492
  .venv/…/mcp/server/fastmcp/server.py   _setup_handlers() → line 302

Collision rule
--------------
When two different backend servers expose a tool with the same flat name,
both tools are SKIPPED and a ``flat_tool_name_collision`` warning is logged.
This is a deliberate security/correctness invariant: exposing an
ambiguously-routed tool could silently send a call to the wrong backend.
Single-backend deployments never hit this path.

Members of ONE group are the exception (#857): they expose the same tool
names by definition -- that is what makes them interchangeable -- so they are
collapsed into their group rather than colliding with each other, and calls
dispatch through the group id so member selection stays with the group's
strategy.

Generation and serving
----------------------
What a caller would be shown is generated by :func:`generate_projection`: a
synchronous read of the registry and the resolver that needs no request and
returns a comparable :class:`Projection` (#1367). Serving ``tools/list`` is
that, plus what makes it a response to a client -- the boot warm-up wait, the
listing metrics, the management tools and the cache-scope meta. Keeping the two
apart is what lets anything other than a client ask what the front door holds.

Mode gate
---------
All logic here is active ONLY when the topology mode is ``"front_door"``.
In ``"egress"`` mode the handlers are not replaced and the default hangar_*
surface is fully intact.
"""

from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
import functools
import hashlib
import json
import logging
from types import MappingProxyType
import uuid
from typing import Any

from mcp.shared.inbound import MCP_PARAM_HEADER_PREFIX, find_invalid_x_mcp_header

from mcp_hangar.domain.exceptions import ConfigurationError
from mcp_hangar.domain.policies.header_exposure import get_header_exposure_policy

from mcp_hangar._sdk_compat import FastMCP, lowlevel_server
from mcp_hangar._sdk_compat import (
    METHOD_NOT_FOUND,
    ListToolsResult,
    Tool as MCPTool,
    is_modern_protocol_version,
    make_mcp_error,
)

from .. import metrics as prometheus_metrics
from ..application.read_models.tool_projection import get_tool_projection_registry
from ..context import PARAM_VALIDATION_STATE_ATTR, get_identity_context
from ..logging_config import should_log_now
from ..domain.services import progress_relay
from ..domain.services.governance_overlays import read_as_one_set
from ..domain.services.tool_access_resolver import get_tool_access_resolver, PolicyKind
from ..tasks_wire import HEADER_MISMATCH
from .catalogue_warmup import is_warming, wait_for_catalogue
from .flat_call_log import as_client_result, logging_each_call, note_failure
from .projection_metrics import expose_change_count, observe_served_listing
from .resource_link_read_through import project_result_uris
from .served_tool_names import projection_changed_error_data, remember_served, was_served_to_caller

logger = logging.getLogger(__name__)

# --- SEP-2549 cache-scope advertisement for projected lists (issue #292) ------
#
# SEP-2549 defines ``cacheScope`` / ``ttlMs`` as caching hints on list results so
# downstream caches know whether — and for how long — a list response may be
# reused.  ``mcp.types.ListToolsResult`` predates the SEP and has no typed
# top-level ``cacheScope`` / ``ttlMs`` fields (only ``_meta``/``nextCursor``/
# ``tools``), so we advertise the hints under the result's ``_meta`` using the
# SEP-2549 field names.
#
# Cross-tenant isolation is the whole point here.  The hangar fronts MANY tenants
# behind a SINGLE endpoint, and each tenant's ``tools/list`` is a distinct,
# per-request projection.  SEP-2549's bare ``"private"`` enum relies on the
# downstream cache correctly keying by authorization context; if it does not, it
# could serve tenant A's list to tenant B.  To make cross-tenant reuse
# STRUCTURALLY impossible even for a naive cache that keys only on the advertised
# scope, we emit a DISTINCT, stable, opaque scope TOKEN per tenant instead of a
# shared constant.
#
# Fail-closed: when the tenant is unknown (``None``/empty) we emit a unique,
# non-shareable per-request ``no-store`` token so a cache can never get a second
# hit on it — never a shared or global scope.
CACHE_SCOPE_META_KEY = "cacheScope"
CACHE_TTL_META_KEY = "ttlMs"

# Conservative freshness hint (SEP-2549 ``ttlMs`` is in milliseconds).  Small on
# purpose: the projection is cheap to rebuild and changes to a tenant's tool
# surface (withdrawals, policy edits) must propagate quickly.
PROJECTED_LIST_CACHE_TTL_MS = 5_000

# Prefix for real, per-tenant shareable-within-tenant scope tokens.
_TENANT_SCOPE_PREFIX = "tenant"
# Prefix for the fail-closed, non-shareable per-request scope tokens.
_NO_STORE_SCOPE_PREFIX = "no-store"


def derive_tenant_cache_scope(tenant_id: str | None) -> str:
    """Derive a per-tenant SEP-2549 ``cacheScope`` token (pure, unit-testable).

    Properties (relied on by the cross-tenant isolation tests):

    * Two DIFFERENT tenants get DIFFERENT tokens.
    * The SAME tenant gets the SAME token every time (stable).
    * It is NEVER a shared/global constant across tenants.
    * FAIL CLOSED: an unknown tenant (``None`` or empty) yields a unique,
      non-shareable per-request ``no-store`` token that a cache can never reuse,
      and which can never equal a real tenant's token.

    The tenant id is hashed so the raw tenant identifier does not leak into the
    advertised scope; the hash is stable, so the token is stable per tenant.

    Args:
        tenant_id: The calling tenant's id, or ``None``/empty if unknown.

    Returns:
        An opaque, per-tenant (or per-request, when unknown) scope token.
    """
    if not tenant_id:
        # Unknown tenant -> narrowest possible scope.  A fresh uuid guarantees
        # the token is unique to this single response, so any downstream cache
        # keyed on it can never produce a cross-request (or cross-tenant) hit.
        return f"{_NO_STORE_SCOPE_PREFIX}:{uuid.uuid4().hex}"

    digest = hashlib.sha256(tenant_id.encode("utf-8")).hexdigest()[:32]
    return f"{_TENANT_SCOPE_PREFIX}:{digest}"


def build_projected_list_cache_meta(tenant_id: str | None) -> dict[str, Any]:
    """Build the ``_meta`` cache-scope block for a projected list response.

    Attaches the SEP-2549 ``cacheScope`` (per-tenant, fail-closed) and a
    conservative ``ttlMs`` freshness hint.

    Args:
        tenant_id: The calling tenant's id, or ``None`` if unknown.

    Returns:
        A ``_meta`` dict carrying ``cacheScope`` and ``ttlMs``.
    """
    return {
        CACHE_SCOPE_META_KEY: derive_tenant_cache_scope(tenant_id),
        CACHE_TTL_META_KEY: PROJECTED_LIST_CACHE_TTL_MS,
    }


def _member_to_groups() -> dict[str, tuple[str, ...]]:
    """Map each group member's server id to every group that owns it, in config order.

    The same map ``hangar_call`` governs a member named directly by: it is
    read from the executor, so the two paths cannot disagree about which
    groups own a member. Imported lazily for the reason `_groups` gives.
    """
    from ..server.tools.batch.executor import owners_by_member

    return owners_by_member()


def _member_to_group() -> dict[str, str]:
    """Map each member of exactly one group to that group: the id the front door routes it through.

    Group members are interchangeable by definition, so the flat projection
    treats a member of one group as that ONE logical server: dispatch goes to
    the group so member selection stays with the group's strategy (#857), and
    prompts, resources and the listing's metrics collapse it the same way.

    A member of several groups is left out, so it routes to ITSELF. No one
    group's selection strategy can speak for it, and a call naming the member
    is the call ``hangar_call`` governs by every group that owns it: its own
    policy, each group's policy, withdrawals and pins, deny wins.

    Governance does not read this map: it reads every owner, from
    `_member_to_groups`.
    """
    return {member: owners[0] for member, owners in _member_to_groups().items() if len(owners) == 1}


def _groups() -> dict[str, Any]:
    """The loaded groups. Imported lazily: `server.bootstrap` imports this module back (#894)."""
    from ..server.bootstrap.composition import GROUPS

    return GROUPS


def _withdrawal_scopes(mcp_server: str) -> tuple[str, ...]:
    """Every id a withdrawal for *mcp_server* can have been declared under (#1037, #1210).

    For a group id that is the group AND each of its members, and the union is
    fail-closed: any member's withdrawal hides the item for the whole group.
    Members are interchangeable by definition (#857), so an item withdrawn on
    one of two identical backends is not a state an operator can have meant --
    and the surfaces that ask about a group (prompts, resources) ask under the
    group id alone, so a member's declaration was previously invisible to them.

    For a MEMBER id it is symmetric: the member itself and every group that
    owns it, via `_member_to_groups()`, so a member of several groups is
    withdrawn by a withdrawal on any of them, as ``hangar_call`` withdraws it.
    Without this half a `withdrawn:` declared on the group was written under
    the group id and never consulted, because listing and calling always ask
    under the member id.

    For anything else -- a plain server id -- it is the id itself, which is
    what every caller had.
    """
    group = _groups().get(mcp_server)
    if group is not None:
        return (mcp_server, *(member.id for member in group.members))
    return (mcp_server, *_member_to_groups().get(mcp_server, ()))


def _policy_scopes(mcp_server: str) -> list[tuple[str, str | None, str | None]]:
    """Every ``(server id, group id, member server id)`` the access policy is asked under for *mcp_server*.

    The scopes of the call the front door makes for it, so a tool shown here is
    one that call is allowed:

    * a member of one group routes through its group: the group's scope, with
      this member as the one the group routes to;
    * a member of several groups routes to itself: the scopes ``hangar_call``
      asks for a member named directly, its own and one per owning group;
    * a group id (what `_upstream_ids` hands the prompts and resources
      surfaces, having collapsed the member) is the group's scope. Which member
      answers is not known yet, so a member policy cannot be applied (#1036);
    * anything else is its own scope.
    """
    owners = _member_to_groups().get(mcp_server, ())
    if len(owners) > 1:
        from ..server.tools.batch.executor import member_policy_scopes

        return member_policy_scopes(mcp_server, owners)
    if owners:
        return [(owners[0], owners[0], mcp_server)]
    if mcp_server in _groups():
        return [(mcp_server, mcp_server, None)]
    return [(mcp_server, None, None)]


def is_governed_allowed(mcp_server: str, name: str, *, kind: PolicyKind, tenant_id: str | None) -> bool:
    """May *tenant_id* see and use *name* on *mcp_server*? (#1028)

    The single decision behind every projected surface -- tools, prompts and
    resources alike. Both halves of the tool answer, applied per kind:

    * the withdrawal overlay (config or runtime, per tenant or for all), and
    * the effective access policy from the one resolver, asked under every
      scope `_policy_scopes` names. A group member is checked against each
      group that owns it, and deny wins, as ``hangar_call`` checks it.

    Listing and fetching call this same function, so a thing that was not shown
    cannot be fetched and a thing that was shown can be -- and neither surface
    can drift from the other by growing its own copy of the rule. A denied item
    is answered exactly like a nonexistent one at every call site, which is what
    stops the front door being a cross-tenant enumeration oracle (#905).

    For resources, *name* is the UPSTREAM URI -- see
    :func:`resource_link_read_through._deliverable` for why.
    """
    registry = get_tool_projection_registry()
    for scope in _withdrawal_scopes(mcp_server):
        if registry.is_withdrawn(scope, name, kind=kind, tenant_id=tenant_id):
            if scope != mcp_server:
                logger.debug(
                    "withdrawn_by_group_member scope=%s asked_as=%s kind=%s name=%s", scope, mcp_server, kind, name
                )
            return False
    resolver = get_tool_access_resolver()
    return all(
        resolver.is_allowed(
            server_id, name, kind=kind, group_id=group_id, member_id=tenant_id, member_server_id=member_server_id
        )
        for server_id, group_id, member_server_id in _policy_scopes(mcp_server)
    )


def _build_flat_map(tenant_id: str | None) -> dict[str, tuple[str, str]]:
    """Build a per-request flat_name -> (mcp_server, tool) map for *tenant_id*, against one configuration (#1431).

    The listing and a flat call's routing both come from here. Each entry reads
    the withdrawals, the access policies and the `header_exposure` blocks, and
    a reload swaps those one after another. Built through `read_as_one_set`,
    the map is one configuration's, never a mix of two: a reload that moves a
    control from `tools.deny_list: [t]` to `tool_projection.withdrawn: [t]`
    cannot list or route `t` while it swaps. `_flat_map_now` has the rules. Its
    only effects are logging and the verdict caches, which report a schema
    once however often it is asked about, so it can be built again.
    """
    return read_as_one_set(functools.partial(_flat_map_now, tenant_id))


def _flat_map_now(
    tenant_id: str | None,
) -> dict[str, tuple[str, str]]:
    """Build a per-request flat_name -> (mcp_server, tool) map for *tenant_id*. Through `_build_flat_map`.

    Rules applied:
    1. Only tools that are active (not withdrawn) for *tenant_id*.
    2. Only tools the resolver allows for *tenant_id* (member-scope policy).
    3. On flat-name collision across two servers: both entries are dropped and
       a ``flat_tool_name_collision`` warning is emitted.  See module docstring.

    Args:
        tenant_id: The tenant making the request; ``None`` means no identity
            (resolver will deny everything in front_door mode, so the map is
            effectively empty but we still build it correctly).

    Returns:
        Mapping of flat tool name to ``(mcp_server_id, tool_name)``.
    """
    registry = get_tool_projection_registry()
    group_of = _member_to_group()

    flat: dict[str, tuple[str, str]] = {}
    # Track names that collide so we can skip them without re-logging.
    collisions: set[str] = set()

    for raw_proj in registry.all():
        mcp_server = raw_proj.mcp_server
        tool_name = raw_proj.tool

        # Use registry.resolve() to get the overlay-aware projection (runtime +
        # config withdrawals are merged in by the registry, not stored on the raw
        # ToolProjection returned by registry.all()).
        resolved = registry.resolve(mcp_server, tool_name, tenant_id)
        if resolved is None:
            continue

        # Drop withdrawn tools for this tenant (covers both config and runtime overlays).
        if resolved.is_withdrawn_for(tenant_id):
            continue

        # Drop tools denied by policy. A group member is checked against every
        # group that owns it -- the same checks `_gate_tool_access` applies to
        # the call this entry routes to, so a tool shown here is the tool that
        # check will allow. Shared with the prompts and resources surfaces
        # since #1028.
        owner_group = group_of.get(mcp_server)
        if not is_governed_allowed(mcp_server, tool_name, kind="tool", tenant_id=tenant_id):
            continue

        # Shown == callable, and a conforming client drops this one on arrival
        # (#1056). Dropping it here keeps both halves of that invariant: it is
        # absent from the listing and `-32601` on the call.
        if _invalid_header_annotation(resolved) is not None:
            continue

        # What the tool asks a client to put in a header, versus what this
        # operator is willing to have exposed there (#1057).
        if _denied_header_exposure(resolved) is not None:
            continue

        flat_name = tool_name  # FLAT naming: tool name as-is, no server prefix.

        if flat_name in collisions:
            # Already marked as collision; skip silently.
            continue

        if flat_name in flat:
            existing_server, _ = flat[flat_name]
            if group_of.get(existing_server, existing_server) == (owner_group or mcp_server):
                # Same logical server: members of one group expose the same
                # names BY DEFINITION -- that is not ambiguity, keep the first
                # member's entry as the schema source (#857).
                continue
            # Collision across different logical servers: drop the earlier
            # entry too.
            flat.pop(flat_name)
            collisions.add(flat_name)
            logger.warning(
                "flat_tool_name_collision flat_name=%s server_a=%s server_b=%s",
                flat_name,
                existing_server,
                mcp_server,
            )
            continue

        flat[flat_name] = (mcp_server, tool_name)

    return flat


#: Per-(tool, schema version) verdict on the tool's own ``x-mcp-header``
#: annotations. The answer depends only on the schema, so it is settled once
#: per digest rather than on every listing -- a metric that fires per request
#: measures traffic, not the catalogue (#1049).
# ponytail: never evicted; bounded by catalogue size x schema drift. Upgrade:
# clear it where the registry invalidates.
_ANNOTATION_VERDICTS: dict[tuple[str, str, str], str | None] = {}


def _invalid_header_annotation(proj: Any) -> str | None:
    """Why a conforming client must drop this tool, or ``None`` (#1056).

    SEP-2243 makes it a client-side **MUST**: a tool whose ``x-mcp-header``
    annotations fail ``find_invalid_x_mcp_header`` is dropped by the client
    that receives it. Projecting it anyway advertises a tool nobody can call
    and counts it as governance surface we delivered.

    The annotation is not stripped in place: the JCS digest is taken over
    ``{name, description, inputSchema, outputSchema}``, so editing the schema
    would move the digest and read as upstream drift. The tool goes away
    instead, with a reason.
    """
    key = (proj.mcp_server, proj.tool, proj.digest.sha256)
    if key in _ANNOTATION_VERDICTS:
        return _ANNOTATION_VERDICTS[key]

    reason = find_invalid_x_mcp_header(proj.schema.get("inputSchema"))
    _ANNOTATION_VERDICTS[key] = reason
    if reason is not None:
        logger.warning(
            "tool_withheld_invalid_x_mcp_header mcp_server=%s tool=%s reason=%s",
            proj.mcp_server,
            proj.tool,
            reason,
        )
        prometheus_metrics.PROJECTION_WITHDRAWALS_TOTAL.inc(reason="invalid_x_mcp_header")
    return reason


#: Per-(tool, schema version, policy) verdict on what the tool asks a client to
#: expose. Keyed like `_ANNOTATION_VERDICTS` and for the same reason: the answer
#: depends on the schema and the block, never on how often the tool is listed.
_EXPOSURE_VERDICTS: dict[tuple[str, str, str, Any], str | None] = {}


def _denied_header_exposure(proj: Any) -> str | None:
    """The `header_exposure` verdict for this tool, or ``None`` to keep it (#1057).

    SEP-2243's only defence against annotating a secret is a SHOULD NOT. An
    upstream that annotates `api_key` obliges every conforming client to put
    the key in an HTTP header, where every intermediary on the path can read
    it. This is the enforcement point that SHOULD is missing.

    Returns a reason only when the tool must be withheld. ``warn`` -- the
    default, so adopting the block changes nobody's surface -- logs and counts
    but keeps the tool. ``refuse_boot`` raises: the operator asked for the
    catalogue to be unavailable rather than quietly smaller.

    The schema is never edited; see `_invalid_header_annotation`.
    """
    own = get_header_exposure_policy(proj.mcp_server)
    for policy in [own] if own else _group_exposure_policies(proj.mcp_server):
        reason = _exposure_verdict(proj, policy)
        if reason is not None:
            return reason
    return None


def _exposure_verdict(proj: Any, policy: Any) -> str | None:
    """What one `header_exposure` block says about this tool: see `_denied_header_exposure`."""
    # Keyed on the policy VALUE, not its identity: a reload rebuilds the block,
    # and a recycled id() would answer for a policy that no longer exists.
    key = (proj.mcp_server, proj.tool, proj.digest.sha256, policy)
    if key not in _EXPOSURE_VERDICTS:
        _EXPOSURE_VERDICTS[key] = policy.violation(proj.schema.get("inputSchema"))
        if _EXPOSURE_VERDICTS[key] is not None:
            logger.warning(
                "tool_header_exposure_denied mcp_server=%s tool=%s action=%s reason=%s",
                proj.mcp_server,
                proj.tool,
                policy.on_violation,
                _EXPOSURE_VERDICTS[key],
            )
            prometheus_metrics.PROJECTION_WITHDRAWALS_TOTAL.inc(reason=f"header_exposure_{policy.on_violation}")

    reason = _EXPOSURE_VERDICTS[key]
    if reason is None:
        return None
    if policy.on_violation == "refuse_boot":
        raise ConfigurationError(f"header_exposure refuses to serve {proj.mcp_server}/{proj.tool}: {reason}")
    return reason if policy.on_violation == "withdraw" else None


def _group_exposure_policies(mcp_server: str) -> list[Any]:
    """A member inherits the block each group that owns it declared (#1038 scope shape).

    A member of several groups is held to every one of them: the tool is
    withheld when any of their blocks withholds it.
    """
    blocks = (get_header_exposure_policy(group) for group in _member_to_groups().get(mcp_server, ()))
    return [block for block in blocks if block]


def _build_mcp_tool_list(
    flat_map: dict[str, tuple[str, str]],
) -> list[MCPTool]:
    """Convert the flat map to MCP Tool objects using discovered schemas.

    Args:
        flat_map: Mapping from flat name to (mcp_server, tool_name).

    Returns:
        List of MCP Tool objects ready for the tools/list response.
    """
    registry = get_tool_projection_registry()
    tools: list[MCPTool] = []

    for flat_name, (mcp_server, tool_name) in flat_map.items():
        proj = registry.resolve(mcp_server, tool_name)
        if proj is None:
            continue  # Should not happen after _build_flat_map, but be safe.

        # Carry the WHOLE definition, renaming only what the flat surface owns.
        # Hand-picking three keys dropped `title`, `annotations`, `execution`,
        # `icons`, `_meta` -- and `outputSchema` with them, so a client behind the
        # front door had nothing to validate structured output against (#880).
        # `annotations.readOnlyHint` / `destructiveHint` are what a client uses to
        # decide whether a call needs a human in front of it; a projection that
        # discards them makes every tool look alike.
        payload = dict(proj.schema)
        payload["name"] = flat_name
        payload.setdefault("description", "")
        payload.setdefault("inputSchema", {"type": "object", "properties": {}})

        tools.append(
            # Built via model_validate with the wire alias ``inputSchema`` so the
            # same call works on SDK v1 (field ``inputSchema``) and v2 (renamed to
            # ``input_schema``, alias-populated).
            MCPTool.model_validate(payload)
        )

    return tools


@dataclass(frozen=True)
class Projection:
    """The governed tools the front door would show one caller right now (#1367).

    A value, not a response. It carries no cache-scope meta and no `hangar_*`
    management tools, which are a per-principal authorization decision the
    listing appends. It also carries nothing about the request that produced
    it, so something other than a client can ask for one.

    Attributes:
        tenant_id: The caller identity it was generated for. ``None`` is the
            fail-closed no-identity projection, which is empty in front_door.
        routes: Flat name -> ``(mcp_server, tool)``. What a flat ``tools/call``
            dispatches on, so a name that is shown is a name that is callable
            (ADR-022).
        tools: Flat name -> the definition ``tools/list`` serves, in served
            order.

    Equality answers "did the projection change?". Two projections are equal
    when they were generated for the same identity, route the same names to the
    same upstream tools, and carry the same definitions. Order is not part of
    it: a client keys a listing by name, and a re-discovery that reorders the
    registry has changed nothing a client can see. ``tools`` still keeps the
    order, so serving from it is byte-identical. Unhashable, like the mappings
    it holds.
    """

    tenant_id: str | None
    routes: Mapping[str, tuple[str, str]]
    tools: Mapping[str, MCPTool]

    __hash__ = None  # type: ignore[assignment]


def generate_projection(tenant_id: str | None) -> Projection:
    """Generate the projection *tenant_id* would be served right now (#1367).

    The front door's one generation step, separate from answering a request.
    ``tools/list`` serves what this returns, and a flat ``tools/call`` routes
    with the same `_build_flat_map` that produces ``routes``. Synchronous on
    purpose: it reads state and never waits for any.

    Side-effect free with respect to policy and the fleet. It reads the
    projection registry, the withdrawal overlays and the access resolver. It
    never registers a policy, starts or warms an upstream, or waits on the boot
    warm-up. These things do happen while it runs, and each belongs here rather
    than to serving:

    * The ``x-mcp-header`` and ``header_exposure`` verdicts (#1056, #1057) log
      and count ``PROJECTION_WITHDRAWALS_TOTAL`` once per schema version,
      through their verdict caches, and never once per generation. They
      describe the catalogue, not traffic, so whichever generation first meets
      a schema reports it, a listing or an inspection alike.
    * ``header_exposure: refuse_boot`` raises ``ConfigurationError`` on every
      generation that reaches the offending tool. The operator asked for the
      catalogue to be unavailable rather than quietly smaller, and an
      inspection that returned the smaller one would misreport what a client
      gets.
    * The resolver memoises the effective policies it computes and throttles
      its no-tenant warning. That caches an answer; it registers nothing.
    * ``flat_tool_name_collision`` is logged on every generation that meets the
      collision, as it was on every listing.

    These stay with serving, in `_list_projected_tools`: waiting out the
    warm-up (#1231), measuring the listing (`projection_metrics`, #1369) and
    reporting an empty projection (#887). Those measure what a client was
    handed, and an inspection hands nothing to anyone. The management tools,
    the per-POST memo and the cache-scope meta stay there too.
    """
    routes = _build_flat_map(tenant_id)
    tools = _build_mcp_tool_list(routes)
    return Projection(
        tenant_id=tenant_id,
        routes=MappingProxyType(routes),
        tools=MappingProxyType({tool.name: tool for tool in tools}),
    )


#: Causes an empty front-door projection can have. They are indistinguishable
#: from outside -- same 200, same `{"tools": []}` -- and only one of them is a
#: correct answer.
EMPTY_NO_IDENTITY = "no_identity"
EMPTY_NOTHING_DISCOVERED = "nothing_discovered"
EMPTY_FILTERED = "filtered"

# Per-HTTP-request memo of the identity-scoped projection. The SDK's modern
# transport re-invokes tools/list pre-dispatch to resolve Mcp-Param schemas
# (#1049); without this the call rebuilds the routes that listing just built.
_MEMO_ATTR = "hangar_projection"

_MCP_PARAM_PREFIX_LOWER = MCP_PARAM_HEADER_PREFIX.lower()


def _http_request(mcp_ctx: Any) -> Any:
    inner = getattr(mcp_ctx, "request_context", None) or mcp_ctx
    return getattr(inner, "request", None)


def _envelope(mcp_ctx: Any) -> dict[str, Any]:
    """The JSON-RPC message the HTTP request carried, not the nested handler's.

    A pre-dispatch tools/list runs with ctx.method == "tools/list" even when
    the POST was tools/call, so the body -- already buffered on the Starlette
    request as ``_body`` -- is the one source that names the envelope.
    """
    # ponytail: envelope read off the buffered request._body. Upgrade path:
    # the transport passes the envelope method down to the handler.
    body = getattr(_http_request(mcp_ctx), "_body", None)
    if not isinstance(body, (bytes, bytearray)):
        return {}
    try:
        payload = json.loads(body)
    except (ValueError, UnicodeDecodeError):
        return {}
    return payload if isinstance(payload, dict) else {}


def _carries_param_header(mcp_ctx: Any) -> bool:
    headers = getattr(_http_request(mcp_ctx), "headers", None)
    return any(str(key).lower().startswith(_MCP_PARAM_PREFIX_LOWER) for key in headers or ())


def _call_carries_param_check(mcp_ctx: Any) -> bool:
    """The SDK only resolves a schema when arguments or Mcp-Param-* headers exist."""
    params = _envelope(mcp_ctx).get("params")
    arguments = params.get("arguments") if isinstance(params, dict) else None
    return bool(isinstance(arguments, dict) and arguments) or _carries_param_header(mcp_ctx)


def _observe_param_header_skips(mcp_ctx: Any, governed: list[MCPTool], management: list[MCPTool]) -> None:
    """Count an SDK Mcp-Param skip that this pre-dispatch listing made visible (#1053)."""
    if not _call_carries_param_check(mcp_ctx):
        return
    params = _envelope(mcp_ctx).get("params")
    name = params.get("name") if isinstance(params, dict) else None
    match = next((tool for tool in (*governed, *management) if tool.name == name), None)
    if match is None:
        prometheus_metrics.PARAM_HEADER_VALIDATION_SKIPPED_TOTAL.inc(reason="tool_not_listed")
        return
    if find_invalid_x_mcp_header(match.input_schema) is not None:
        prometheus_metrics.PARAM_HEADER_VALIDATION_SKIPPED_TOTAL.inc(reason="invalid_annotation")


def _observe_legacy_param_skip(mcp_ctx: Any) -> None:
    """Handshake-era traffic never runs the Mcp-Param ladder (#1053)."""
    headers = getattr(_http_request(mcp_ctx), "headers", None)
    if headers is None or not hasattr(headers, "get") or not _carries_param_header(mcp_ctx):
        return
    if is_modern_protocol_version(headers.get("mcp-protocol-version")):
        return
    prometheus_metrics.PARAM_HEADER_VALIDATION_SKIPPED_TOTAL.inc(reason="legacy_protocol")


def _memoised_flat_map(mcp_ctx: Any, tenant_id: str | None) -> Mapping[str, tuple[str, str]]:
    """The routes of the projection the pre-dispatch listing generated on this POST, or fresh ones.

    A miss builds the routes alone, with the same `_build_flat_map` that
    `generate_projection` uses. A call needs the routes and not the
    definitions, and building those too would put a schema validation of every
    tool in the catalogue on every call. That would be a serving-path change,
    and this function does not make one.
    """
    memo = getattr(getattr(_http_request(mcp_ctx), "state", None), _MEMO_ATTR, None)
    if isinstance(memo, Projection) and memo.tenant_id == tenant_id:
        return memo.routes
    return _build_flat_map(tenant_id)


def _memoise_flat_map(mcp_ctx: Any, projection: Projection) -> None:
    """Keep this POST's projection for the call it precedes (#1049).

    The projection names the identity it was generated for, so the memo cannot
    answer for another caller.
    """
    state = getattr(_http_request(mcp_ctx), "state", None)
    if state is not None:
        setattr(state, _MEMO_ATTR, projection)


def _mark_param_validation_skipped(mcp_ctx: Any) -> None:
    """Record on this POST that the SDK will dispatch without checking headers.

    The listing this failed in is the SDK's pre-dispatch schema lookup: it
    catches, skips validation and dispatches anyway (fail-open by design). An
    L7 header selector must not then match a header nobody compared against the
    body, so the skip has to reach the evaluator -- and the carrier is
    ``request.state``, not a contextvar, because ``bind_routing_headers``
    rebuilds its mapping from the raw request headers rather than merging into
    it (ADR-025). Same per-POST channel as the projection memo above.
    """
    state = getattr(_http_request(mcp_ctx), "state", None)
    if state is not None:
        setattr(state, PARAM_VALIDATION_STATE_ATTR, True)


def _param_validation_skipped(mcp_ctx: Any) -> bool:
    """Whether this POST's ``Mcp-Param-*`` headers reached dispatch unchecked."""
    return bool(getattr(getattr(_http_request(mcp_ctx), "state", None), PARAM_VALIDATION_STATE_ATTR, False))


#: Whether a call whose ``Mcp-Param-*`` headers could not be validated is
#: refused rather than served (``headers.param_validation.required``, ADR-025
#: Decision 2). Off by default; read at config load and again on every reload
#: (#1424).
_param_validation_required = False


def set_param_validation_required(required: bool) -> None:
    """Apply ``headers.param_validation.required`` from the config file."""
    global _param_validation_required
    _param_validation_required = required


def param_validation_required() -> bool:
    """Whether an unvalidated ``Mcp-Param-*`` call is refused instead of served."""
    return _param_validation_required


async def _list_projected_tools(mcp_ctx: Any, load_management: Any) -> ListToolsResult:
    """Serve this caller's projection, and count it only if the client asked for it.

    Generation is `generate_projection`. Everything here is what makes a
    projection a response to a client: the warm-up wait, the listing metrics,
    the per-POST memo, the management tools and the cache-scope meta. None of
    it runs when a projection is generated for inspection.
    """
    identity = get_identity_context()
    tenant_id: str | None = identity.caller.tenant_id if identity is not None else None

    try:
        projection = generate_projection(tenant_id)
        management = await load_management(mcp_ctx)
    except Exception:  # noqa: BLE001 -- the SDK fail-opens on a failed listing; count, mark, then re-raise
        if _envelope(mcp_ctx).get("method") == "tools/call" and _call_carries_param_check(mcp_ctx):
            prometheus_metrics.PARAM_HEADER_VALIDATION_SKIPPED_TOTAL.inc(reason="listing_failed")
            _mark_param_validation_skipped(mcp_ctx)
        raise

    # Nothing discovered yet, and the boot warm-up still running: wait for it
    # rather than hand back a catalogue the client will cache forever (#1231).
    if await _catalogue_settled(tenant_id, not projection.tools):
        projection = generate_projection(tenant_id)
    governed = list(projection.tools.values())

    # The SDK's pre-dispatch tools/list on a tools/call (#1049) is not a listing
    # the client received: it must not be counted as one.
    if _envelope(mcp_ctx).get("method") != "tools/call":
        # Reported on the governed tools alone. An operator who can see the
        # control plane but no upstream tools is still looking at an empty
        # catalogue, and that is the condition worth a line in the log.
        if not governed:
            _report_empty_projection(tenant_id)
        # Its size by kind and by upstream, and whether it changed (#1369).
        observe_served_listing(projection, management, _member_to_group())
        # What this caller now holds, so a name that later leaves it can say so (#1368).
        remember_served(tool.name for tool in (*governed, *management))
    else:
        _observe_param_header_skips(mcp_ctx, governed, management)

    _memoise_flat_map(mcp_ctx, projection)
    return ListToolsResult(
        tools=governed + management,
        _meta=build_projected_list_cache_meta(tenant_id),
    )


def _classify_empty_projection(tenant_id: str | None) -> str:
    """Why did this projection resolve to nothing?

    Ordered by how wrong the answer is. No identity is a fail-closed deny that
    looks exactly like an empty catalogue; nothing discovered is a replica whose
    boot-time warm-up has not finished or did not succeed; filtered is the one
    case where `[]` is the truth.
    """
    if tenant_id is None:
        return EMPTY_NO_IDENTITY
    if not get_tool_projection_registry().all():
        return EMPTY_NOTHING_DISCOVERED
    return EMPTY_FILTERED


async def _catalogue_settled(tenant_id: str | None, projection_is_empty: bool) -> bool:
    """Wait out the boot warm-up when an empty answer would be knowably wrong (#1231).

    True means the caller should re-read the projection: the warm-up finished
    while we waited, so what was empty a moment ago may not be now.

    One function rather than the condition inline at both call sites, because
    the two must not drift: the listing and the call path have to agree exactly
    on when waiting is legitimate. Waiting where it is not costs a fail-closed
    deny its speed, or delays a true `-32601` for a tool that does not exist.
    """
    if not projection_is_empty or not is_warming():
        return False
    if _classify_empty_projection(tenant_id) != EMPTY_NOTHING_DISCOVERED:
        return False
    return await wait_for_catalogue()


async def _settled_flat_map(mcp_ctx: Any, tenant_id: str | None) -> Mapping[str, tuple[str, str]]:
    """This request's flat map, having waited out the boot warm-up if it was empty.

    The call path's half of #1231, as an assignment rather than a branch at the
    call site: ``register_flat_tool_handlers`` is at the complexity ceiling and
    a wait is not what should push it over.
    """
    flat_map = _memoised_flat_map(mcp_ctx, tenant_id)
    if await _catalogue_settled(tenant_id, not flat_map):
        flat_map = _build_flat_map(tenant_id)
    return flat_map


def _not_found_error(name: str) -> Exception:
    """The ``-32601`` for a name this caller cannot call (#1368).

    Byte for byte the error every such call has always had, unless this
    caller's last listing on this replica served *name*. Then its list is out of
    date, and ``data`` says so with a constant reason. The code and the message
    are the same either way, so a client that does not know the reason reads
    today's error.

    Whether *name* exists for anyone else is never consulted. A name another
    tenant holds, a name policy denies this caller and a name that exists
    nowhere get one answer, as #905 requires. See `served_tool_names`.

    A function rather than a branch in the call handler, which is at the
    complexity ceiling.
    """
    data = projection_changed_error_data() if was_served_to_caller(name) else None
    error: Exception = make_mcp_error(METHOD_NOT_FOUND, f"Tool '{name}' not found", data=data)
    return error


def _report_empty_projection(tenant_id: str | None) -> None:
    """Log and count an empty front-door `tools/list` (#887).

    An operator watching a front door that has just been rolled sees healthy
    pods, a 200, and tenants reporting that everything vanished. Nothing
    distinguished "this tenant has no tools" (correct) from "this replica has
    discovered nothing yet" (wrong, and self-inflicted by a restart).

    Throttled per (reason, tenant): the condition holds for every request while
    it lasts, so the first line is the signal.
    """
    reason = _classify_empty_projection(tenant_id)
    prometheus_metrics.EMPTY_PROJECTION_TOTAL.inc(reason=reason)

    if not should_log_now(f"empty_projection:{reason}:{tenant_id}"):
        return

    if reason == EMPTY_NO_IDENTITY:
        logger.warning(
            "empty_projection reason=no_identity -- front_door served zero tools because the caller "
            "carried no tenant identity. Fail-closed deny, not an empty catalogue: check authentication."
        )
    elif reason == EMPTY_NOTHING_DISCOVERED:
        logger.warning(
            "empty_projection reason=nothing_discovered tenant=%s -- this replica has discovered no tools "
            "at all, so the front door is serving an empty list to a valid tenant. Discovery is per-replica; "
            "front_door warms every configured mcp_server at boot (#885), so seeing this after the first few "
            "seconds means the warm-up failed -- look for front_door_warmup_failed.",
            tenant_id,
        )
    else:
        logger.info(
            "empty_projection reason=filtered tenant=%s -- tools are discovered but policy or withdrawal "
            "removed all of them for this tenant. This is a correct answer.",
            tenant_id,
        )


def _register_caller_progress_forwarder(mcp_ctx: Any) -> str | None:
    """Mint and register an upstream progress token for this call, or ``None`` (#883).

    ``None`` when the caller attached no ``progressToken`` or the context has
    no session to deliver on (the SDK v1 path). The forwarder schedules the
    session's ``send_progress_notification`` onto this loop, because upstream
    progress arrives on the GET stream's reader thread (#882). The upstream is
    asked with a MINTED token, not the caller's: caller tokens are opaque and
    can collide across sessions on a shared upstream client.
    """
    caller_meta = getattr(mcp_ctx, "meta", None) or {}
    caller_token = caller_meta.get("progress_token", caller_meta.get("progressToken"))
    session = getattr(mcp_ctx, "session", None)
    if caller_token is None or session is None:
        return None

    upstream_token = progress_relay.mint_token()
    loop = asyncio.get_running_loop()
    request_id = getattr(mcp_ctx, "request_id", None)

    def _forward(progress: float, total: float | None, message: str | None) -> None:
        asyncio.run_coroutine_threadsafe(
            session.send_progress_notification(
                caller_token,
                progress,
                total=total,
                message=message,
                related_request_id=request_id,
            ),
            loop,
        )

    progress_relay.register(upstream_token, _forward)
    return upstream_token


def _refusing_suspended_sessions(call_tool: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
    """Refuse a suspended session before the flat ``tools/call`` does anything.

    Before the flat map, the warm-up wait and every gate the handler runs
    (GHSA-fhwh-fmq2-7m5c). The refusal is a tool error (``isError``), the shape
    an enforcement refusal from the executor already takes on this path; the
    tool name is the caller's text and is not echoed. A decorator rather than a
    branch in the handler, which is at the complexity ceiling.
    """

    @functools.wraps(call_tool)
    async def guarded(name: str, arguments: dict[str, Any], mcp_ctx: Any = None) -> Any:
        from mcp_hangar._sdk_compat import CallToolResult

        # Lazily, for the same import cycle as the batch package (#894).
        from ..server.session_guard import SessionSuspendedError, refuse_if_session_suspended

        try:
            refuse_if_session_suspended("flat_tool", mcp_ctx)
        except SessionSuspendedError as exc:
            note_failure(exc.reason)
            return CallToolResult.model_validate({"content": [{"type": "text", "text": str(exc)}], "isError": True})
        return await call_tool(name, arguments, mcp_ctx)

    return guarded


def register_flat_tool_handlers(mcp: FastMCP) -> None:
    """Replace the default tools/list and tools/call handlers with flat-projection ones.

    This function is called ONLY in front_door mode.  It re-registers the
    request handlers on ``mcp._mcp_server`` (the underlying lowlevel
    ``MCPServer``), overwriting what ``_setup_handlers()`` set up during
    ``FastMCP.__init__``.

    The list handler builds a per-request flat map keyed by caller tenant_id
    and populates ``_tool_cache``.  The call handler resolves the flat name
    from the per-request flat map and routes through the existing
    enforcement+invoke path (resolver + projection + command_bus) without
    duplicating any enforcement logic.

    Args:
        mcp: The FastMCP server instance to modify.
    """
    low = lowlevel_server(mcp)

    async def _management_tools(mcp_ctx: Any) -> list[MCPTool]:
        """The `hangar_*` tools this caller is authorized to call, if any (#904).

        Empty for an agent principal and for an unauthenticated one, which is
        every caller a front door served before this. Definitions come from the
        registered surface rather than being rebuilt here, so a projected
        management tool carries the same schema the invoke path validates.
        """
        # Deferred for the cycle described at the call handler below (#894).
        from ..server.tools.tool_permissions import management_tools_for

        permitted = management_tools_for(mcp_ctx)
        if not permitted:
            return []
        registered = mcp.list_tools()
        if hasattr(registered, "__await__"):
            registered = await registered
        return [tool for tool in registered if tool.name in permitted]

    async def _flat_list_tools(mcp_ctx: Any = None) -> ListToolsResult:
        """Per-request filtered tools/list for front_door mode.

        Reads tenant_id from the identity context (bound at request time by
        the identity middleware, see issue #249).  Projects all active backend
        tools visible to this tenant from the ToolProjectionRegistry, applying
        both member-scope policy (resolver.filter_tools) and withdrawal status.

        Since #904 the `hangar_*` surface is no longer absent by construction:
        it is absent for a caller that may not call it, which is every agent
        principal and every unauthenticated one. An operator holding the
        permissions those tools require sees them here, so one gateway can serve
        an agent without a control plane and an operator with one -- the reason
        the mode-wide swap was not enough (ADR-022).

        The response advertises a per-tenant SEP-2549 ``cacheScope`` under
        ``_meta`` (fail-closed to a non-shareable ``no-store`` token when the
        tenant is unknown) so a downstream cache can never serve one tenant's
        list to another (issue #292).
        """
        return await _list_projected_tools(mcp_ctx, _management_tools)

    # A suspended session never reaches the body (GHSA-fhwh-fmq2-7m5c).
    @_refusing_suspended_sessions
    async def _flat_call_tool(name: str, arguments: dict[str, Any], mcp_ctx: Any = None) -> Any:
        """Flat tool call dispatch for front_door mode.

        Resolution:
        1. Re-build the flat map for this tenant (same filtering as list).
        2. Resolve flat name → (mcp_server, tool).
        3. Route through the EXISTING enforcement path, the configured
           BatchExecutor ``hangar_call`` runs, so that policy checks,
           withdrawal rejection, TOCTOU and the configured interceptors are
           handled identically to the batch path — no enforcement duplication.

        A name that is not an upstream tool may still be a management tool this
        caller is authorized for (#904), in which case it is dispatched to the
        registered `hangar_*` implementation. The check is the same one the
        listing used, so a tool that was shown is callable and one that was not
        is still `-32601`: not-shown and not-callable are the same decision.

        A task the upstream answers with is governed as ``hangar_call`` governs
        it, and the caller gets its SEP-2663 task result (#1394).

        Protocol errors:
        - Unknown flat name (absent from tenant's current list) → McpError
          with code METHOD_NOT_FOUND (-32601).
        - Tool denied/withdrawn between list and call (TOCTOU) → BatchExecutor
          enforcement path returns ToolAccessDeniedError / ToolWithdrawnError,
          which surfaces as a CallToolResult(isError=True).  The backend is
          never invoked.
        """
        from mcp_hangar._sdk_compat import CallToolResult

        # Imported lazily: the batch package reaches `server.bootstrap`, which
        # imports this module back. At module scope that makes this module
        # impossible to import first in a fresh interpreter (#894).
        from ..server.tools.batch import CallSpec, configured_executor
        from ..server.tools.tool_permissions import management_tools_for
        from .flat_call_tasks import govern_flat_call

        identity = get_identity_context()
        tenant_id: str | None = identity.caller.tenant_id if identity is not None else None
        _observe_legacy_param_skip(mcp_ctx)

        # The opt-in half of ADR-025: refuse a call whose Mcp-Param-* headers
        # nobody could check, rather than serving it unvalidated. The mark is
        # only ever set when a check was owed (arguments or Mcp-Param-* headers
        # present) and the pre-dispatch listing raised -- `tool_not_listed` is
        # already a -32601 below, and a handshake-era request is an era rather
        # than a failure. Default off: this converts an upstream availability
        # problem into a client-visible refusal, which only an operator who
        # cannot serve an unvalidated header should choose.
        if _param_validation_required and _param_validation_skipped(mcp_ctx):
            # HEADER_MISMATCH is a slight overstatement -- we do not know the
            # header disagrees with the body, only that nobody could check --
            # and it is still the right code: a third code for one class
            # ("your headers are not trustworthy here") is worse for a client
            # than one code with an accurate message. The wire shape differs
            # from the SDK's own refusal, which is a pre-dispatch HTTP 400;
            # this one is a JSON-RPC error out of the handler.
            raise make_mcp_error(
                HEADER_MISMATCH,
                f"Tool '{name}': the request's Mcp-Param-* headers could not be validated against its body",
            )

        # Re-build flat map for this request's tenant (handles TOCTOU at the
        # map level; enforcement below also re-checks independently). Reuse
        # the per-request memo when the SDK already listed for Mcp-Param
        # validation on this same POST (#1049).
        # Waits out the boot warm-up when the map is empty (#1231): a call that
        # lands mid-warm-up would otherwise be refused -32601 for a tool that is
        # about to exist -- and on a multi-replica front door that is a call the
        # client listed successfully against another replica.
        flat_map = await _settled_flat_map(mcp_ctx, tenant_id)

        if name not in flat_map:
            if name in management_tools_for(mcp_ctx):
                # The registered tool, reached through the SDK's own dispatch so
                # it passes `mcp_tool_wrapper` -- which authorizes it a second
                # time (#909). The context is forwarded because that is where the
                # wrapper reads the principal from; without it the tool would be
                # refused as anonymous.
                return await mcp.call_tool(name, arguments or {}, context=mcp_ctx)
            # Unknown flat name → -32601, carrying a staleness reason only when
            # this caller's last listing served the name (#1368).
            raise _not_found_error(name)

        mcp_server_id, tool_name = flat_map[name]
        # A member of one group dispatches through its GROUP so member
        # selection stays with the group's strategy (round-robin, canary,
        # health) -- the executor resolves the group id to a concrete member
        # itself (#857). A member of several groups dispatches to itself, and
        # the executor governs it by every group that owns it, as it governs
        # `hangar_call` naming that member. See `_member_to_group`.
        mcp_server_id = _member_to_group().get(mcp_server_id, mcp_server_id)

        # Relay the caller's progressToken (#883): the upstream is asked with a
        # freshly minted token, and progress arriving on the standing GET
        # stream (#882) is translated back onto this caller's session.
        upstream_token = _register_caller_progress_forwarder(mcp_ctx)

        # Delegate to the executor `hangar_call` runs.  This reuses the full
        # enforcement path:
        #   resolver.is_tool_allowed → withdrawal check → command_bus.send
        # No enforcement logic is duplicated here.  It is the configured one,
        # read per call: a `BatchExecutor()` built here has an empty interceptor
        # pipeline, so this path ran none of the configured validators (#1425).
        # Run in a worker thread: the executor BLOCKS until the upstream
        # answers, and blocking this loop would freeze every other request on
        # the connection -- including the very progress notifications this
        # call asked for.
        call_id = uuid.uuid4().hex[:12]
        executor = configured_executor()
        try:
            batch = await asyncio.to_thread(
                executor.execute,
                batch_id=call_id,
                calls=[
                    CallSpec(
                        index=0,
                        call_id=call_id,
                        mcp_server=mcp_server_id,
                        tool=tool_name,
                        arguments=arguments or {},
                        progress_token=upstream_token,
                    )
                ],
                max_concurrency=1,
                global_timeout=30.0,
                fail_fast=False,
                # The same request context `hangar_call` threads through (#1492).
                # Without it the executor read an empty `params._meta`, so this
                # call forwarded no protocol negotiation, no inbound W3C trace
                # context and no routing headers -- and a current-spec upstream,
                # which mints a task only for a client that declared the tasks
                # extension, never made one for a front-door caller.
                request_ctx=mcp_ctx,
            )
        finally:
            progress_relay.unregister(upstream_token)

        # A task the upstream created gets its owner recorded exactly as
        # `hangar_call` records it, before the caller sees it (#1394).
        result, created_task = govern_flat_call(batch.results)
        if not result.success:
            note_failure(result.error_type)  # the text below does not carry the code; the log line does
            # Surface enforcement failures as tool errors (isError=True),
            # not as unhandled exceptions, so the MCP envelope stays valid.
            return CallToolResult.model_validate(
                {
                    "content": [{"type": "text", "text": result.error or "tool call failed"}],
                    "isError": True,
                }
            )

        # Namespace every resource URI we are about to hand this tenant with its
        # owning upstream, and remember the resource_links, so following one on
        # this gateway resolves and agrees with the catalogue (#889, #1025).
        project_result_uris(tenant_id, mcp_server_id, result.result)

        # Success — a governed task is answered with its SEP-2663 task result
        # (#1394), anything else with the raw result dict the lowlevel handler wraps.
        return created_task or (result.result if result.result is not None else {})

    # Register the handlers, replacing the defaults. SDK v1's lowlevel Server
    # exposes list_tools()/call_tool() registration decorators; SDK v2 dropped
    # them for add_request_handler(method, params_type, handler) with a
    # (ctx, params) -> HandlerResult signature.
    # One log line per call, whatever its outcome (#1362), written by the
    # outermost wrapper so a call refused before the handler body -- a suspended
    # session -- is logged like any other. On v2 the caller's result is built
    # inside that scope, so the line cannot say `ok` for a call the SDK then
    # rejects as a `-32602` (#1404). v1 is left as it was: its lowlevel server
    # builds the caller's result itself, out of shapes `CallToolResult` does not
    # accept on its own, and `mcp==2.0.0` is what ships.
    if hasattr(low, "list_tools"):  # SDK v1
        low.list_tools()(_flat_list_tools)
        low.call_tool(validate_input=False)(logging_each_call(_flat_call_tool))
    else:  # SDK v2
        from mcp_types import CallToolRequestParams, PaginatedRequestParams

        from .asgi import (
            bind_caller_identity,
            bind_routing_headers,
            release_caller_identity,
            release_routing_headers,
        )

        # `ctx` is the SDK's per-request context and carries the HTTP request,
        # so the authenticated principal is right here. It used to be dropped:
        # both handlers then read `identity_context_var`, which the ASGI wrapper
        # sets in a different task, found None, and `_compute_effective_policy`
        # took its `member_id is None` deny-all branch -- front-door mode
        # projected zero tools to every authenticated tenant, with the empty
        # list indistinguishable from "no tools configured".
        # `ctx` is also what carries the principal into the management-surface
        # decision (#904), so it is threaded down rather than only used to bind
        # the tenant: `identity_context_var` carries ids and not roles, and the
        # question here is what this caller may call.
        async def _list_v2(ctx: Any, params: Any) -> ListToolsResult:
            token = bind_caller_identity(ctx)
            try:
                return await _flat_list_tools(ctx)
            finally:
                release_caller_identity(token)

        async def _call_v2(ctx: Any, params: Any) -> Any:
            token = bind_caller_identity(ctx)
            # The L7 egress evaluator selects on Mcp-Param-* (#1058), and the
            # aggregate that runs it is several frames and one worker thread
            # away; this is the last place the HTTP request is in hand.
            headers_token = bind_routing_headers(ctx)
            try:
                return await _call_v2_inner(params, ctx)
            finally:
                release_routing_headers(headers_token)
                release_caller_identity(token)

        # The caller's result is built by `as_client_result`, inside the log's
        # scope: an upstream answer that cannot become a `CallToolResult` is the
        # caller's tool error and the line's `tool_error`, not an `ok` line and
        # a `-32602` the SDK raised after the line was written (#1404).
        logged_call = logging_each_call(as_client_result(_flat_call_tool))

        async def _call_v2_inner(params: Any, ctx: Any) -> Any:
            return await logged_call(params.name, params.arguments or {}, ctx)

        low.add_request_handler("tools/list", PaginatedRequestParams, _list_v2)
        low.add_request_handler("tools/call", CallToolRequestParams, _call_v2)


def maybe_register_flat_tool_handlers(mcp: Any) -> bool:
    """Install the flat tool surface when topology says ``front_door``.

    In ``front_door`` external agents see flat backend tool names instead of the
    ``hangar_*`` meta-API; in ``egress`` (the default) this is a no-op and the
    meta-API is preserved unchanged. Returns whether the handlers were installed.

    Shared by both builders: the gate used to live only in ``MCPServerFactory``,
    which has no production call site, so ``front_door`` configured on the
    shipped ``serve --http`` silently kept serving the meta-API — the mode
    appeared to do nothing (#596).
    """
    from ..domain.services.tool_access_resolver import is_front_door

    if not is_front_door():
        return False

    register_flat_tool_handlers(mcp)
    expose_change_count()
    logger.info("flat_tool_handlers_registered (topology_mode=front_door)")
    return True
