# When must an MCP server reject a tool call that its alias table would otherwise resolve? **Date of Record:** 2026 November 5 **Scope:** Applies to Model Context Protocol servers that expose tools through a versioned schema registry with backward-compatible aliases (deprecated tool names mapped to current handlers). Does not apply to stateless single-version servers or to alias-only shims with no schema validation. ## Core Distinction There are two distinct failure surfaces in MCP capability negotiation: *capability advertisement* (what the server claims it can do during `initialize`) and *call-time schema conformance* (whether a specific `tools/call` payload matches the schema version the client believes it is speaking). Backward-compatible aliasing collapses these two surfaces into one lookup path, and this is where the invariant breaks. The non-obvious rule: **an alias resolving successfully is not evidence that the call is valid.** An alias maps a *name* to a *current handler*; it does not map the *argument shape the client constructed* to the *schema the handler now expects*. When a client negotiates an old schema version, aliases the deprecated tool name, and then sends arguments shaped by the old schema, the alias table resolves the name while the handler silently receives a payload that violates its current invariants. ## What Was Observed A server registered `search_v1` (schema v1, argument `query: str`) and later `search` (schema v2, argument `query: str`, `filters: dict` required, `limit: int` defaulting to 20). The alias table mapped `search_v1 → search`. A client that had negotiated protocol capability `tools.schema_version = "1"` issued `tools/call` with `name: "search_v1"` and `arguments: {"query": "foo"}`. The alias resolved. The v2 handler executed. `filters` was absent, so the handler fell back to an internal default of `{}` — which the v2 schema declared *required but nullable*, not *optional*. The call returned results, but they were unfiltered and unbounded in a way the v1 contract never permitted. No error surfaced. The client's capability negotiation had promised it would only receive v1-shaped responses; it received v2 semantics under a v1 name. The failure was invisible for eleven days because the handler's default masked the missing required field. It only manifested when `filters` defaulting to `{}` caused a downstream tenant-scope leak in a multi-tenant deployment. ## Condition of Validity Alias resolution is only safe when **all** of the following hold: 1. The negotiated schema version for the session is `>=` the schema version that introduced the current handler's argument contract, **or** the handler performs explicit down-conversion of arguments to the negotiated version before validation. 2. Every field that is required in the current schema but absent in the aliased (older) schema has an *explicit, non-null, documented* default that preserves the older contract's semantics — not an implicit `None`/`{}` fallback. 3. The alias entry records the *maximum* schema version the alias is permitted to serve, and the dispatcher rejects calls where `session.schema_version > alias.max_served_version` is false but the handler's contract exceeds it. Condition 3 is the one teams omit. Aliases are usually stored as a flat `dict[str, str]` (old name → new name). That structure cannot express "this alias serves v1 clients only." It must become `dict[str, AliasEntry]` where `AliasEntry` carries `target`, `serves_versions: frozenset`, and `argument_downconverter`. ## Decision Boundary The dispatcher must reject — not resolve — when an alias matches but the negotiated schema version is incompatible with the target handler's contract and no down-converter is registered. The rejection must be a protocol-level `tools/call` error with a structured `code: "schema_version_mismatch"`, not a silent success and not a generic `-32602`. Do **not** reject merely because the client used a deprecated name; aliasing exists precisely to permit that. Reject when the *name* resolves to a handler whose *argument contract* cannot be satisfied by the *negotiated version's* argument shape. The alias table answers "is this name known?"; the schema gate answers "is this call valid?" — these are separate questions and must be separate code paths. ## Operational Effect After introducing an explicit schema gate ahead of alias resolution: - Deprecated-name calls from v1 clients either succeeded with down-converted arguments (registered converters) or failed fast with `schema_version_mismatch` (unregistered), eliminating the silent-default class of leaks. - The eleven-day detection latency dropped to per-call rejection; the tenant-scope leak became a 400-class error at the boundary instead of a 200 with wrong data. - Alias table growth became auditable: each `AliasEntry` declares its served versions, so removing a version from the registry surfaces every alias that can no longer serve it at startup, not at call time under production load. The invariant to carry forward: **negotiation constrains the argument shape; aliasing only translates the name.** Conflating them produces calls that resolve and execute but violate the contract the client negotiated — the most expensive class of MCP failure because it returns `200 OK`. ```python from dataclasses import dataclass from typing import Callable, Mapping, Any class AliasSchemaMismatch(RuntimeError): """Raised when an alias resolves but the negotiated schema version cannot satisfy the target handler's argument contract.""" @dataclass(frozen=True) class AliasEntry: target: str serves_versions: frozenset[int] downconverter: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None ALIASES: dict[str, AliasEntry] = { "search_v1": AliasEntry( target="search", serves_versions=frozenset({1}), downconverter=None, # no v1->v2 argument converter registered ), } def dispatch(tool_name: str, arguments: Mapping[str, Any], session_schema_version: int) -> str: entry = ALIASES.get(tool_name) if entry is None: return tool_name # not an alias; normal resolution path if session_schema_version not in entry.serves_versions: raise AliasSchemaMismatch( f"alias {tool_name!r} does not serve schema version " f"{session_schema_version}; serves {sorted(entry.serves_versions)}" ) if entry.downconverter is None and session_schema_version < max(entry.serves_versions): # Alias claims to serve this version but cannot reshape arguments # to the target's current contract. Refuse rather than let the # handler apply an implicit default. raise AliasSchemaMismatch( f"alias {tool_name!r} has no argument down-converter for " f"schema version {session_schema_version}" ) if entry.downconverter is not None: arguments = entry.downconverter(arguments) return entry.target # Example: v1 client calling deprecated name trips the gate. try: dispatch("search_v1", {"query": "foo"}, session_schema_version=1) except AliasSchemaMismatch as exc: print(f"rejected at boundary: {exc}") ```