Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,14 @@ match redirect URIs by exact string comparison, so if you registered such a URI
release (with the trailing slash) and the registration is persisted in `TokenStorage`, re-register
the client so the stored value matches what the SDK now transmits.

`AuthSettings` now sets `url_preserve_empty_path=True` for the same reason: a path-less
`issuer_url` (or `resource_server_url`) passed as a string keeps its empty path, so the authorization
server advertises `issuer` as `https://as.example.com` rather than `https://as.example.com/` in its
metadata. Previously the trailing slash was added before the model saw the value, leaving the served
issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. Passing an
already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the
preserved form.

### Lowlevel `Server`: `subscribe` capability now correctly reported

Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior.
Expand Down
8 changes: 7 additions & 1 deletion src/mcp/server/auth/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import AnyHttpUrl, BaseModel, Field
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field


class ClientRegistrationOptions(BaseModel):
Expand All @@ -13,6 +13,12 @@ class RevocationOptions(BaseModel):


class AuthSettings(BaseModel):
# Preserve empty URL paths so a path-less issuer/resource passed as a string keeps its
# canonical form (no trailing slash). RFC 8414/9207 issuer comparison is exact string
# comparison, so a spurious trailing slash would break it. See PR #2925 for the metadata
# models; this applies the same to the server's own configured URLs.
model_config = ConfigDict(url_preserve_empty_path=True)

issuer_url: AnyHttpUrl = Field(
...,
description="OAuth authorization server URL that issues tokens for this resource server.",
Expand Down
27 changes: 26 additions & 1 deletion tests/server/auth/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import pytest
from pydantic import AnyHttpUrl

from mcp.server.auth.routes import validate_issuer_url
from mcp.server.auth.routes import build_metadata, validate_issuer_url
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions


def test_validate_issuer_url_https_allowed():
Expand Down Expand Up @@ -45,3 +46,27 @@
def test_validate_issuer_url_query_rejected():
with pytest.raises(ValueError, match="query"):
validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1"))


def test_auth_settings_preserves_path_less_issuer():
"""A path-less issuer passed as a string keeps its canonical form (no trailing slash)."""
settings = AuthSettings(
issuer_url="https://as.example.com", # type: ignore[arg-type]
resource_server_url="https://rs.example.com", # type: ignore[arg-type]
)
assert str(settings.issuer_url) == "https://as.example.com"
assert str(settings.resource_server_url) == "https://rs.example.com"


def test_build_metadata_serves_issuer_without_trailing_slash():
"""The served issuer matches the configured one exactly (RFC 8414/9207 string comparison)."""
settings = AuthSettings(
issuer_url="https://as.example.com", # type: ignore[arg-type]
resource_server_url="https://rs.example.com", # type: ignore[arg-type]

Check warning on line 65 in tests/server/auth/test_routes.py

View check run for this annotation

Claude / Claude Code Review

New type: ignore comments in tests violate AGENTS.md guideline

These two new tests add four new `# type: ignore[arg-type]` comments, which AGENTS.md explicitly asks to avoid ("Avoid adding new # pragma: no cover, # type: ignore, or # noqa comments"). The same string-validation path can be exercised without the ignores via `AuthSettings.model_validate({"issuer_url": "https://as.example.com", "resource_server_url": "https://rs.example.com"})`, matching the pattern already used in tests/client/test_auth.py for OAuthMetadata.
Comment on lines +51 to +65

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.

🟡 These two new tests add four new # type: ignore[arg-type] comments, which AGENTS.md explicitly asks to avoid ("Avoid adding new # pragma: no cover, # type: ignore, or # noqa comments"). The same string-validation path can be exercised without the ignores via AuthSettings.model_validate({"issuer_url": "https://as.example.com", "resource_server_url": "https://rs.example.com"}), matching the pattern already used in tests/client/test_auth.py for OAuthMetadata.

Extended reasoning...

What the issue is

The two tests added in this PR (test_auth_settings_preserves_path_less_issuer and test_build_metadata_serves_issuer_without_trailing_slash) construct AuthSettings by passing plain strings to the AnyHttpUrl-typed issuer_url and resource_server_url fields, suppressing the resulting type errors with four new # type: ignore[arg-type] comments (lines 54–55 and 64–65). AGENTS.md (Code Quality / Coverage section, ~line 100) explicitly says: "Avoid adding new # pragma: no cover, # type: ignore, or # noqa comments", and even provides the audit command maintainers run before pushing — git diff origin/main... | grep -E 'type: ignore' — which would flag exactly these four lines. These are also the only # type: ignore[arg-type] occurrences introduced into the tests tree by this PR.

Why the tests are written this way (and why the ignores are still avoidable)

The tests genuinely need string input: passing an already-built AnyHttpUrl("https://as.example.com") object would normalize the URL to https://as.example.com/ at construction time, before url_preserve_empty_path=True ever applies, defeating the entire purpose of the test. So the string input is correct — but the constructor-with-ignore pattern is not the only way to get it. AuthSettings.model_validate({...}) accepts a plain dict and runs the exact same Pydantic string-validation path, with no static type error and therefore no ignore needed.

Existing precedent in the codebase

The repo already uses exactly this pattern for the same scenario: tests/client/test_auth.py (around lines 2724–2734) uses OAuthMetadata.model_validate({...}) with string URL values precisely to test the url_preserve_empty_path behavior introduced in PR #2925, with a comment explaining it matches the wire path. Following that precedent here keeps the new tests consistent with the existing test for the sibling fix.

Step-by-step proof that the alternative is equivalent

  1. Today: AuthSettings(issuer_url="https://as.example.com", ...) — Pydantic coerces the string through AnyHttpUrl validation inside the model, where url_preserve_empty_path=True from model_config applies, so str(settings.issuer_url) == "https://as.example.com". The type checker complains because the declared type is AnyHttpUrl, hence the ignores.
  2. Alternative: AuthSettings.model_validate({"issuer_url": "https://as.example.com", "resource_server_url": "https://rs.example.com"}) — the dict values go through the identical field validation (string → AnyHttpUrl with the model's config), producing the same settings object and the same assertions passing. model_validate accepts Any-typed input, so no type: ignore is needed.
  3. The rest of both tests (the assertions and the build_metadata call) is unchanged.

How to fix

Replace the keyword-argument construction with AuthSettings.model_validate({...}) in both tests and drop the four # type: ignore[arg-type] comments. This is a minor, non-blocking convention issue — the tests are otherwise correct and valuable — so it's filed as a nit.

)
metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions())

served = metadata.model_dump(mode="json", exclude_none=True)
assert served["issuer"] == "https://as.example.com"
assert served["authorization_endpoint"] == "https://as.example.com/authorize"
assert served["token_endpoint"] == "https://as.example.com/token"
Loading