Files
modelcontextprotocol--pytho…/tests/shared/test_auth_utils.py
Max Isbey c53aefd293 Close cancelled HTTP exchanges and harden auth validation
Review-feedback round on the conformance burn-down:

- Cancelled requests no longer leave the legacy streamable-HTTP POST
  hanging. The dispatcher emits a RequestSettled marker when a handler is
  cancelled without producing a response; the transport consumes it by
  closing the per-request stream, so the POST's SSE stream terminates
  without a response frame and JSON-response mode completes with 204 No
  Content (the client treats 202/204 alike). Per-request streams are
  released instead of leaking until session teardown, and a handler that
  survives the cancellation still delivers its normal response. The
  marker is type-visible on the dispatcher write stream and is stripped
  by every serializing transport, so it can never appear on a wire.
- A bearer token whose audience cannot be canonicalized (out-of-range or
  non-numeric port) is now rejected with the standard 401 invalid_token
  instead of raising through the auth middleware as a 500.
- The bundled authorization server's /register now accepts only https
  redirect URIs or http on a loopback host; other schemes on loopback
  hosts (ftp, ws, javascript, custom) are rejected.
- OAuth client scope selection falls back to the caller-configured
  OAuthClientMetadata.scope when neither the WWW-Authenticate challenge
  nor protected-resource metadata names scopes, matching the TypeScript
  SDK, so the documented migration path works as written.
- The cross-dispatcher contract that handler-raised MCPError subclasses
  surface to callers as plain MCPError is now pinned by an explicit test
  and documented; rehydrate with from_error when the subclass matters.
- Docs: migration notes for the bearer-challenge wire-shape changes and
  the cancellation wire spellings; story READMEs updated to the landed
  error contract; strict-capabilities doc corrected to state that
  resources/unsubscribe is gated by the base resources capability only.
2026-06-28 11:30:26 +00:00

185 lines
9.3 KiB
Python

"""Tests for OAuth 2.0 Resource Indicators utilities."""
import pytest
from pydantic import HttpUrl
from mcp.shared.auth_utils import check_resource_allowed, check_token_audience, resource_url_from_server_url
# Tests for resource_url_from_server_url function
def test_resource_url_from_server_url_removes_fragment():
"""Fragment should be removed per RFC 8707."""
assert resource_url_from_server_url("https://example.com/path#fragment") == "https://example.com/path"
assert resource_url_from_server_url("https://example.com/#fragment") == "https://example.com/"
def test_resource_url_from_server_url_preserves_path():
"""Path should be preserved."""
assert (
resource_url_from_server_url("https://example.com/path/to/resource") == "https://example.com/path/to/resource"
)
assert resource_url_from_server_url("https://example.com/") == "https://example.com/"
assert resource_url_from_server_url("https://example.com") == "https://example.com"
def test_resource_url_from_server_url_preserves_query():
"""Query parameters should be preserved."""
assert resource_url_from_server_url("https://example.com/path?foo=bar") == "https://example.com/path?foo=bar"
assert resource_url_from_server_url("https://example.com/?key=value") == "https://example.com/?key=value"
def test_resource_url_from_server_url_preserves_port():
"""Non-default ports should be preserved."""
assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path"
assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/"
def test_resource_url_from_server_url_strips_default_port():
"""An explicit default port is equivalent to omitting it (RFC 3986 §6.2.3)."""
assert resource_url_from_server_url("https://example.com:443/mcp") == "https://example.com/mcp"
assert resource_url_from_server_url("http://example.com:80/mcp") == "http://example.com/mcp"
# Only the scheme's own default is stripped — :80 on https is significant.
assert resource_url_from_server_url("https://example.com:80/mcp") == "https://example.com:80/mcp"
# IPv6 brackets survive the rewrite.
assert resource_url_from_server_url("https://[::1]:443/mcp") == "https://[::1]/mcp"
def test_check_token_audience_ignores_default_port():
"""A token issued for `https://h:443/mcp` is for the server at `https://h/mcp`."""
assert check_token_audience("https://h:443/mcp", "https://h/mcp") is True
assert check_token_audience("https://h/mcp", "https://h:443/mcp") is True
assert check_token_audience("https://h:8443/mcp", "https://h/mcp") is False
def test_check_token_audience_treats_an_unparseable_audience_as_a_mismatch():
"""A token audience whose port cannot be parsed does not identify this server.
SDK-defined: RFC 3986's grammar puts no upper bound on port digits, so an AS can
legitimately issue a token for `https://h:99999/mcp`; urllib refuses to parse such
ports, and that canonicalization failure must read as a mismatch, not an error.
"""
assert check_token_audience("https://h:99999/mcp", "https://h/mcp") is False
assert check_token_audience("https://h:abc/mcp", "https://h/mcp") is False
def test_check_token_audience_treats_trailing_slash_variants_as_one_resource():
"""`https://h/api/` and `https://h/api` are the same audience, in either direction.
SDK-defined interop tolerance per authorization.mdx's canonical-URI note (both
spellings of one resource circulate; the slashless form is merely recommended), and
required at root because pydantic's `AnyHttpUrl` renders `https://h` as `https://h/`
while the spec's example token request sends the slashless form.
"""
assert check_token_audience("https://h/api/", "https://h/api") is True
assert check_token_audience("https://h/api", "https://h/api/") is True
assert check_token_audience("https://h", "https://h/") is True
def test_check_token_audience_rejects_sibling_and_child_paths():
"""Trailing-slash tolerance does not loosen path equality: siblings and children mismatch."""
assert check_token_audience("https://h/api123", "https://h/api") is False
assert check_token_audience("https://h/api/sub", "https://h/api") is False
def test_resource_url_from_server_url_lowercase_scheme_and_host():
"""Scheme and host should be lowercase for canonical form."""
assert resource_url_from_server_url("HTTPS://EXAMPLE.COM/path") == "https://example.com/path"
assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/"
def test_resource_url_from_server_url_handles_pydantic_urls():
"""Should handle Pydantic URL types."""
url = HttpUrl("https://example.com/path")
assert resource_url_from_server_url(url) == "https://example.com/path"
def test_resource_url_from_server_url_raises_on_unparseable_port():
"""An out-of-range or non-numeric port raises ValueError, as documented.
SDK-defined: the canonicalizer stays strict for its trusted own-config callers;
`check_token_audience` wraps the untrusted token side. The message is urllib's,
so only the exception type is pinned.
"""
with pytest.raises(ValueError):
resource_url_from_server_url("https://example.com:99999/mcp")
with pytest.raises(ValueError):
resource_url_from_server_url("https://example.com:abc/mcp")
# Tests for check_resource_allowed function
def test_check_resource_allowed_identical_urls():
"""Identical URLs should match."""
assert check_resource_allowed("https://example.com/path", "https://example.com/path") is True
assert check_resource_allowed("https://example.com/", "https://example.com/") is True
assert check_resource_allowed("https://example.com", "https://example.com") is True
def test_check_resource_allowed_different_schemes():
"""Different schemes should not match."""
assert check_resource_allowed("https://example.com/path", "http://example.com/path") is False
assert check_resource_allowed("http://example.com/", "https://example.com/") is False
def test_check_resource_allowed_different_domains():
"""Different domains should not match."""
assert check_resource_allowed("https://example.com/path", "https://example.org/path") is False
assert check_resource_allowed("https://sub.example.com/", "https://example.com/") is False
def test_check_resource_allowed_different_ports():
"""Different ports should not match."""
assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False
assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False
def test_check_resource_allowed_hierarchical_matching():
"""Child paths should match parent paths."""
# Parent resource allows child resources
assert check_resource_allowed("https://example.com/api/v1/users", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/mcp/server", "https://example.com/mcp") is True
# Exact match
assert check_resource_allowed("https://example.com/api", "https://example.com/api") is True
# Parent cannot use child's token
assert check_resource_allowed("https://example.com/api", "https://example.com/api/v1") is False
assert check_resource_allowed("https://example.com/", "https://example.com/api") is False
def test_check_resource_allowed_path_boundary_matching():
"""Path matching should respect boundaries."""
# Should not match partial path segments
assert check_resource_allowed("https://example.com/apiextra", "https://example.com/api") is False
assert check_resource_allowed("https://example.com/api123", "https://example.com/api") is False
# Should match with trailing slash
assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True
def test_check_resource_allowed_trailing_slash_handling():
"""Trailing slashes should be handled correctly."""
# With and without trailing slashes
assert check_resource_allowed("https://example.com/api/", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/api", "https://example.com/api/") is True
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api") is True
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api/") is True
def test_check_resource_allowed_case_insensitive_origin():
"""Origin comparison should be case-insensitive."""
assert check_resource_allowed("https://EXAMPLE.COM/path", "https://example.com/path") is True
assert check_resource_allowed("HTTPS://example.com/path", "https://example.com/path") is True
assert check_resource_allowed("https://Example.Com:8080/api", "https://example.com:8080/api") is True
def test_check_resource_allowed_empty_paths():
"""Empty paths should be handled correctly."""
assert check_resource_allowed("https://example.com", "https://example.com") is True
assert check_resource_allowed("https://example.com/", "https://example.com") is True
assert check_resource_allowed("https://example.com/api", "https://example.com") is True