aiohttp-tiny-mcp 0.3.6__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/PKG-INFO +2 -2
  2. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/README.md +1 -1
  3. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/pyproject.toml +1 -1
  4. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/pyproject.toml.orig +1 -1
  5. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/__init__.py +7 -0
  6. aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/auth.py +274 -0
  7. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/endpoint.py +64 -44
  8. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/extensions.py +2 -1
  9. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/http_sse.py +46 -15
  10. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/registry.py +28 -4
  11. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/stdio.py +4 -2
  12. aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/tasks.py +37 -0
  13. aiohttp_tiny_mcp-0.3.6/src/aiohttp_tiny_mcp/auth.py +0 -129
  14. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/LICENSE +0 -0
  15. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/adapter.py +0 -0
  16. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/client.py +0 -0
  17. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/client_base.py +0 -0
  18. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  19. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  20. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/console.js +0 -0
  21. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  22. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/core.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/hub.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/models.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/postgres.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  36. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/py.typed +0 -0
  37. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/redis.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/schema.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/skills.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/specs.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/sse.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  46. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
  47. {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohttp-tiny-mcp
3
- Version: 0.3.6
3
+ Version: 0.4.0
4
4
  Summary: Remote MCP server and client library for aiohttp
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -235,7 +235,7 @@ Start with [the documentation overview](https://mosquito.github.io/aiohttp-tiny-
235
235
  2. [Quickstart](https://mosquito.github.io/aiohttp-tiny-mcp/quickstart.html): a complete server, launch command, and client call.
236
236
  3. [How the server fits together](https://mosquito.github.io/aiohttp-tiny-mcp/pieces.html): a conversation across two workers and each object's lifetime.
237
237
  4. [Using Exchange](https://mosquito.github.io/aiohttp-tiny-mcp/guide/exchange.html): request context, progress, questions, and state.
238
- 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): bearer-token verification and OAuth resource metadata.
238
+ 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer, custom subclasses, multiple policies, and permissions.
239
239
  6. [Stores and hubs](https://mosquito.github.io/aiohttp-tiny-mcp/deployment/stores.html): shared backend contracts and deployment requirements.
240
240
  7. [Extensions and skills](https://mosquito.github.io/aiohttp-tiny-mcp/guide/extensions.html): custom methods, skill directories, and resources for older clients.
241
241
 
@@ -193,7 +193,7 @@ Start with [the documentation overview](https://mosquito.github.io/aiohttp-tiny-
193
193
  2. [Quickstart](https://mosquito.github.io/aiohttp-tiny-mcp/quickstart.html): a complete server, launch command, and client call.
194
194
  3. [How the server fits together](https://mosquito.github.io/aiohttp-tiny-mcp/pieces.html): a conversation across two workers and each object's lifetime.
195
195
  4. [Using Exchange](https://mosquito.github.io/aiohttp-tiny-mcp/guide/exchange.html): request context, progress, questions, and state.
196
- 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): bearer-token verification and OAuth resource metadata.
196
+ 5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer, custom subclasses, multiple policies, and permissions.
197
197
  6. [Stores and hubs](https://mosquito.github.io/aiohttp-tiny-mcp/deployment/stores.html): shared backend contracts and deployment requirements.
198
198
  7. [Extensions and skills](https://mosquito.github.io/aiohttp-tiny-mcp/guide/extensions.html): custom methods, skill directories, and resources for older clients.
199
199
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.6"
3
+ version = "0.4.0"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.6"
3
+ version = "0.4.0"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from .auth import Authentication, Authorization, BasicAuth, Principal, StaticBasicAuth, Unauthorized
5
6
  from .client import Client
6
7
  from .client_base import ClientError, Elicitor
7
8
  from .core import (
@@ -47,6 +48,9 @@ __all__ = [
47
48
  "Answer",
48
49
  "AnswerAction",
49
50
  "AudioContent",
51
+ "Authentication",
52
+ "Authorization",
53
+ "BasicAuth",
50
54
  "BlobResourceContents",
51
55
  "CallToolResult",
52
56
  "Client",
@@ -68,6 +72,7 @@ __all__ = [
68
72
  "NeedInput",
69
73
  "namespace",
70
74
  "PromptMessage",
75
+ "Principal",
71
76
  "Registry",
72
77
  "SseEndpoint",
73
78
  "RequestStates",
@@ -75,8 +80,10 @@ __all__ = [
75
80
  "SessionRecord",
76
81
  "SessionStore",
77
82
  "StdioClient",
83
+ "StaticBasicAuth",
78
84
  "TextContent",
79
85
  "TextResourceContents",
86
+ "Unauthorized",
80
87
  "elicit",
81
88
  "elicit_accept",
82
89
  "elicit_cancel",
@@ -0,0 +1,274 @@
1
+ """Extensible HTTP authentication, Basic credentials, and OAuth Bearer tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from base64 import b64decode
8
+ from binascii import Error as Base64Error
9
+ from collections.abc import Iterable, Mapping, Sequence
10
+ from dataclasses import dataclass, field
11
+ from secrets import compare_digest
12
+ from typing import Any, Protocol, runtime_checkable
13
+ from urllib.parse import urlsplit
14
+
15
+ from aiohttp import web
16
+
17
+ WELL_KNOWN = "/.well-known/oauth-protected-resource"
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Principal:
22
+ """Identity and claims returned by a token verifier."""
23
+
24
+ subject: str | None = None
25
+ client_id: str = ""
26
+ issuer: str | None = None
27
+ scopes: frozenset[str] = frozenset()
28
+ expires_at: float | None = None
29
+ claims: Mapping[str, Any] = field(default_factory=dict)
30
+ namespace: str | None = None
31
+
32
+ def holds(self, wanted: Iterable[str]) -> frozenset[str]:
33
+ """Return required scopes absent from this principal."""
34
+ return frozenset(wanted) - self.scopes
35
+
36
+ @property
37
+ def expired(self) -> bool:
38
+ return self.expires_at is not None and self.expires_at < time.time()
39
+
40
+ @property
41
+ def identity(self) -> str:
42
+ """Return an issuer-qualified subject or client identifier."""
43
+ if self.subject:
44
+ return f"{self.issuer or ''}|{self.subject}"
45
+ return f"{self.issuer or ''}|client:{self.client_id}"
46
+
47
+
48
+ @runtime_checkable
49
+ class TokenVerifier(Protocol):
50
+ """Verify a bearer token for this resource."""
51
+
52
+ async def verify(self, token: str) -> Principal | None: ...
53
+
54
+
55
+ class Unauthorized(Exception):
56
+ """Authentication or authorization failed."""
57
+
58
+ def __init__(self, error: str, description: str, status: int = 401) -> None:
59
+ super().__init__(description)
60
+ self.error = error
61
+ self.description = description
62
+ self.status = status
63
+
64
+
65
+ class Authentication(ABC):
66
+ """Subclass this policy and pass an instance to `Registry(auth=...)`.
67
+
68
+ Implement `authenticate` and `challenge`. Return a verified `Principal`
69
+ or `None` for invalid credentials. Raise `Unauthorized` for a specific
70
+ refusal. HTTP endpoints enforce expiry, required scopes, session ownership,
71
+ and namespaces after authentication. Policies publish no metadata by default.
72
+ """
73
+
74
+ bind_sessions: bool = True
75
+ namespace_from_token: bool = True
76
+ required_scopes: Sequence[str] = ()
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ bind_sessions: bool = True,
82
+ namespace_from_token: bool = True,
83
+ required_scopes: Sequence[str] = (),
84
+ ) -> None:
85
+ self.bind_sessions = bind_sessions
86
+ self.namespace_from_token = namespace_from_token
87
+ self.required_scopes = tuple(required_scopes)
88
+
89
+ @abstractmethod
90
+ async def authenticate(self, request: web.Request) -> Principal | None:
91
+ """Verify this HTTP request. The full request is available to the policy."""
92
+
93
+ @abstractmethod
94
+ def challenge(self, refusal: Unauthorized) -> str:
95
+ """Return the WWW-Authenticate value for this policy."""
96
+
97
+ def check(self, principal: Principal | None) -> Principal:
98
+ """Reject invalid identities and missing server-wide scopes."""
99
+ if principal is None or principal.expired:
100
+ raise Unauthorized("invalid_credentials", "valid credentials required")
101
+ missing = principal.holds(self.required_scopes)
102
+ if missing:
103
+ raise Unauthorized(
104
+ "insufficient_scope",
105
+ f"missing scope: {', '.join(sorted(missing))}",
106
+ status=403,
107
+ )
108
+ return principal
109
+
110
+ @property
111
+ def metadata_path(self) -> str | None:
112
+ """Return an optional metadata route, relative to the site root."""
113
+ return None
114
+
115
+ def metadata(self) -> dict[str, Any]:
116
+ """Return metadata when the policy declares a metadata path."""
117
+ return {}
118
+
119
+
120
+ class BasicAuth(Authentication):
121
+ """Subclass `verify` to authenticate Basic credentials against your user store.
122
+
123
+ Credentials use standard Base64 and UTF-8. Query credentials are not read.
124
+ Use `StaticBasicAuth` for one configured username and password.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ *,
130
+ realm: str = "mcp",
131
+ bind_sessions: bool = True,
132
+ namespace_from_token: bool = True,
133
+ required_scopes: Sequence[str] = (),
134
+ ) -> None:
135
+ super().__init__(
136
+ bind_sessions=bind_sessions,
137
+ namespace_from_token=namespace_from_token,
138
+ required_scopes=required_scopes,
139
+ )
140
+ if not realm.isascii() or any(ord(char) < 32 or ord(char) == 127 for char in realm):
141
+ raise ValueError("realm must contain only printable ASCII characters")
142
+ self.realm = realm
143
+
144
+ @abstractmethod
145
+ async def verify(self, username: str, password: str) -> Principal | None:
146
+ """Return the verified identity, or None when credentials do not match."""
147
+
148
+ async def authenticate(self, request: web.Request) -> Principal | None:
149
+ header = request.headers.get("Authorization", "")
150
+ parts = header.split()
151
+ if len(parts) != 2 or parts[0].lower() != "basic":
152
+ return None
153
+ try:
154
+ decoded = b64decode(parts[1], validate=True).decode("utf-8")
155
+ except (ValueError, Base64Error, UnicodeDecodeError):
156
+ return None
157
+ username, separator, password = decoded.partition(":")
158
+ if not separator or any(ord(char) < 32 or ord(char) == 127 for char in decoded):
159
+ return None
160
+ return await self.verify(username, password)
161
+
162
+ def challenge(self, refusal: Unauthorized) -> str:
163
+ realm = self.realm.replace("\\", "\\\\").replace('"', '\\"')
164
+ return f'Basic realm="{realm}", charset="UTF-8"'
165
+
166
+
167
+ class StaticBasicAuth(BasicAuth):
168
+ """Basic authentication for one configured account. Use HTTPS in production."""
169
+
170
+ def __init__(
171
+ self,
172
+ username: str,
173
+ password: str,
174
+ *,
175
+ realm: str = "mcp",
176
+ scopes: Iterable[str] = (),
177
+ bind_sessions: bool = True,
178
+ namespace_from_token: bool = True,
179
+ required_scopes: Sequence[str] = (),
180
+ ) -> None:
181
+ super().__init__(
182
+ realm=realm,
183
+ bind_sessions=bind_sessions,
184
+ namespace_from_token=namespace_from_token,
185
+ required_scopes=required_scopes,
186
+ )
187
+ if ":" in username or any(
188
+ ord(char) < 32 or ord(char) == 127 for char in username + password
189
+ ):
190
+ raise ValueError("Basic credentials cannot contain controls or a colon in the username")
191
+ if not username or not password:
192
+ raise ValueError("username and password must be non-empty")
193
+ self._username = username.encode("utf-8")
194
+ self._password = password.encode("utf-8")
195
+ self._principal = Principal(subject=username, scopes=frozenset(scopes))
196
+
197
+ async def verify(self, username: str, password: str) -> Principal | None:
198
+ matched = compare_digest(username.encode("utf-8"), self._username)
199
+ matched &= compare_digest(password.encode("utf-8"), self._password)
200
+ return self._principal if matched else None
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class Authorization(Authentication):
205
+ """Configuration for an OAuth protected resource server."""
206
+
207
+ verifier: TokenVerifier
208
+ resource: str
209
+ authorization_servers: Sequence[str] = ()
210
+ scopes_supported: Sequence[str] | None = None
211
+ required_scopes: Sequence[str] = ()
212
+ resource_name: str | None = None
213
+ documentation: str | None = None
214
+ bind_sessions: bool = True
215
+ namespace_from_token: bool = True
216
+
217
+ @property
218
+ def metadata_path(self) -> str:
219
+ """Return this resource's RFC 9728 metadata path."""
220
+ path = urlsplit(self.resource).path.rstrip("/")
221
+ return f"{WELL_KNOWN}{path}"
222
+
223
+ def metadata(self) -> dict[str, Any]:
224
+ """Build this resource's RFC 9728 metadata document."""
225
+ found: dict[str, Any] = {"resource": self.resource}
226
+ if self.authorization_servers:
227
+ found["authorization_servers"] = list(self.authorization_servers)
228
+ if self.scopes_supported is not None:
229
+ found["scopes_supported"] = list(self.scopes_supported)
230
+ if self.resource_name:
231
+ found["resource_name"] = self.resource_name
232
+ if self.documentation:
233
+ found["resource_documentation"] = self.documentation
234
+ found["bearer_methods_supported"] = ["header"]
235
+ return found
236
+
237
+ def challenge(self, refusal: Unauthorized) -> str:
238
+ """Build a Bearer challenge with a metadata URL."""
239
+ parts = [
240
+ f'error="{refusal.error}"',
241
+ f'error_description="{refusal.description}"',
242
+ f'resource_metadata="{self.metadata_url}"',
243
+ ]
244
+ return "Bearer " + ", ".join(parts)
245
+
246
+ @property
247
+ def metadata_url(self) -> str:
248
+ split = urlsplit(self.resource)
249
+ return f"{split.scheme}://{split.netloc}{self.metadata_path}"
250
+
251
+ async def principal(self, authorization: str | None) -> Principal:
252
+ """Verify an Authorization header and return its principal."""
253
+ if not authorization or not authorization.lower().startswith("bearer "):
254
+ raise Unauthorized("invalid_request", "authorization required")
255
+ found = await self.verifier.verify(authorization[len("bearer ") :].strip())
256
+ if found is None or found.expired:
257
+ raise Unauthorized("invalid_token", "the token is not valid for this resource")
258
+ return self.check(found)
259
+
260
+ async def authenticate(self, request: web.Request) -> Principal:
261
+ """Verify the request's Bearer header using the configured token verifier."""
262
+ return await self.principal(request.headers.get("Authorization"))
263
+
264
+
265
+ __all__ = [
266
+ "WELL_KNOWN",
267
+ "Authentication",
268
+ "Authorization",
269
+ "BasicAuth",
270
+ "Principal",
271
+ "StaticBasicAuth",
272
+ "TokenVerifier",
273
+ "Unauthorized",
274
+ ]
@@ -7,15 +7,14 @@ import json
7
7
  import logging
8
8
  import re
9
9
  from collections.abc import Mapping
10
- from contextlib import suppress
11
10
  from dataclasses import replace
12
- from functools import cached_property
11
+ from functools import cached_property, partial
13
12
  from typing import Any
14
13
 
15
14
  from aiohttp import web
16
15
 
17
16
  from .adapter import Adapter
18
- from .auth import Authorization, Unauthorized
17
+ from .auth import Authentication, Principal, Unauthorized
19
18
  from .core import (
20
19
  Call,
21
20
  DecodeFailure,
@@ -46,10 +45,12 @@ from .sessions import (
46
45
  )
47
46
  from .sse import SSEResponse
48
47
  from .subscriptions import relays, wanted
48
+ from .tasks import stop
49
49
 
50
50
  log = logging.getLogger("aiohttp_tiny_mcp")
51
51
 
52
52
  MAY_ASK = frozenset({Operation.CALL_TOOL, Operation.GET_PROMPT, Operation.READ_RESOURCE})
53
+ AUTH_POLICY: web.RequestKey[Authentication] = web.RequestKey("mcp_auth_policy", Authentication)
53
54
 
54
55
 
55
56
  def origin_pattern(spec: str) -> re.Pattern[str]:
@@ -127,10 +128,10 @@ class Endpoint:
127
128
  ) -> list[web.RouteDef]:
128
129
  """The endpoint, and where a client looks to find out how to reach it.
129
130
 
130
- The metadata route is included whenever tokens are verified, because a
131
- client that has no token learns where to get one from there and
132
- nowhere else. Pass `metadata=False` where this application cannot
133
- serve that path -- see `metadata_routes`.
131
+ Metadata routes are included when a policy declares them. OAuth Bearer
132
+ policies publish protected-resource metadata; Basic policies do not.
133
+ Pass `metadata=False` when this application cannot serve those paths.
134
+ See `metadata_routes`.
134
135
  """
135
136
  log.debug("MCP endpoint at %s, named %r", path, name)
136
137
  found = [web.route("*", path, self.view, name=name)]
@@ -141,33 +142,40 @@ class Endpoint:
141
142
  def metadata_routes(self, *, name: str | None = "mcp") -> list[web.RouteDef]:
142
143
  """RFC 9728 metadata, for the application that owns the site root.
143
144
 
144
- Empty where nothing verifies tokens. The path comes from the resource
145
+ Empty when no policy declares metadata. The path comes from the resource
145
146
  URL, and RFC 8615 puts a well-known URI directly under the authority,
146
147
  so a prefix must not reach it: an endpoint mounted with `add_subapp`
147
148
  takes `routes(metadata=False)` and leaves these to the root
148
149
  application.
149
150
  """
150
- auth = self.registry.auth
151
- if auth is None:
152
- log.debug("no resource metadata route: nothing verifies tokens")
153
- return []
154
- log.debug("resource metadata at %s, for resource %s", auth.metadata_path, auth.resource)
155
- return [
156
- web.get(
157
- auth.metadata_path,
158
- self.metadata,
159
- name=f"{name}-resource-metadata" if name else None,
151
+ found: list[web.RouteDef] = []
152
+ documents: dict[str, dict[str, Any]] = {}
153
+ for auth in self.registry.auth_policies:
154
+ path = auth.metadata_path
155
+ if path is None:
156
+ continue
157
+ document = auth.metadata()
158
+ if path in documents:
159
+ if documents[path] != document:
160
+ raise ValueError(f"conflicting authentication metadata at {path}")
161
+ continue
162
+ documents[path] = document
163
+ suffix = f"-policy{len(found)}" if found else ""
164
+ found.append(
165
+ web.get(
166
+ path,
167
+ partial(self.metadata, auth=auth),
168
+ name=f"{name}-resource-metadata{suffix}" if name else None,
169
+ )
160
170
  )
161
- ]
171
+ return found
162
172
 
163
- async def metadata(self, request: web.Request) -> web.Response:
173
+ async def metadata(self, request: web.Request, *, auth: Authentication) -> web.Response:
164
174
  """RFC 9728: what this resource is and who issues tokens for it."""
165
- auth = self.registry.auth
166
- assert auth is not None, "the metadata route is only added with auth"
167
175
  return web.json_response(auth.metadata(), headers={"Cache-Control": "public, max-age=3600"})
168
176
 
169
- async def verified(self, request: web.Request) -> Any:
170
- """Who is calling, or `None` where nothing verifies tokens.
177
+ async def verified(self, request: web.Request) -> Principal | None:
178
+ """Return the verified caller, or None when no authentication is configured.
171
179
 
172
180
  Raises `Unauthorized`, which the caller turns into the refusal a
173
181
  client can act on. The namespace is set from what was verified, so
@@ -175,19 +183,34 @@ class Endpoint:
175
183
  checked rather than by a header the caller chose. An application that
176
184
  set its own namespace first keeps it.
177
185
  """
178
- auth = self.registry.auth
179
- if auth is None:
186
+ policies = self.registry.auth_policies
187
+ if not policies:
180
188
  return None
181
- principal = await auth.principal(request.headers.get("Authorization"))
182
- if auth.namespace_from_token and current() is None:
183
- namespace.set(principal.identity)
184
- return principal
189
+ refusals: list[Unauthorized] = []
190
+ for auth in policies:
191
+ try:
192
+ principal = auth.check(await auth.authenticate(request))
193
+ except Unauthorized as refusal:
194
+ refusals.append(refusal)
195
+ continue
196
+ request[AUTH_POLICY] = auth
197
+ if auth.namespace_from_token and current() is None:
198
+ namespace.set(
199
+ principal.namespace if principal.namespace is not None else principal.identity
200
+ )
201
+ return principal
202
+ # Preserve scope failures when another policy merely found no credentials.
203
+ raise next((refusal for refusal in refusals if refusal.status == 403), refusals[-1])
185
204
 
186
- def refuse(self, auth: Authorization, refusal: Unauthorized) -> web.Response:
205
+ def refuse(self, refusal: Unauthorized) -> web.Response:
187
206
  return web.json_response(
188
207
  {"error": refusal.error, "error_description": refusal.description},
189
208
  status=refusal.status,
190
- headers={"WWW-Authenticate": auth.challenge(refusal)},
209
+ headers={
210
+ "WWW-Authenticate": ", ".join(
211
+ auth.challenge(refusal) for auth in self.registry.auth_policies
212
+ )
213
+ },
191
214
  )
192
215
 
193
216
  def setup(
@@ -282,7 +305,7 @@ class Endpoint:
282
305
  principal = await self.verified(request)
283
306
  except Unauthorized as refusal:
284
307
  assert self.registry.auth is not None
285
- return self.refuse(self.registry.auth, refusal)
308
+ return self.refuse(refusal)
286
309
 
287
310
  raw = await request.read()
288
311
  pre = Preamble.of(raw, request.headers, request.query)
@@ -395,7 +418,7 @@ class Endpoint:
395
418
  principal = await self.verified(request)
396
419
  except Unauthorized as refusal:
397
420
  assert self.registry.auth is not None
398
- return self.refuse(self.registry.auth, refusal)
421
+ return self.refuse(refusal)
399
422
 
400
423
  try:
401
424
  record = await self.load_session(request, principal)
@@ -419,9 +442,7 @@ class Endpoint:
419
442
  if transport is None or transport.is_closing():
420
443
  break
421
444
  finally:
422
- relay.cancel()
423
- with suppress(asyncio.CancelledError, ConnectionError):
424
- await relay
445
+ await stop(relay)
425
446
  return response
426
447
 
427
448
  async def relay_notifications(
@@ -457,7 +478,9 @@ class Endpoint:
457
478
  log.debug("ignoring Last-Event-ID %r: not from this hub", last)
458
479
  return await hub.subscribe(where, wait=wait)
459
480
 
460
- def owns(self, record: SessionRecord | None, principal: Any) -> bool:
481
+ def owns(
482
+ self, record: SessionRecord | None, principal: Principal | None, request: web.Request
483
+ ) -> bool:
461
484
  """Whether this caller may use this session.
462
485
 
463
486
  A session id travels in a header, so a copied one is a credential.
@@ -465,7 +488,7 @@ class Endpoint:
465
488
  held in this process, which is what lets a session opened on one
466
489
  worker be used on another.
467
490
  """
468
- auth = self.registry.auth
491
+ auth = request.get(AUTH_POLICY)
469
492
  if record is None or auth is None or not auth.bind_sessions:
470
493
  return True
471
494
  owner = stored_owner(record)
@@ -495,7 +518,7 @@ class Endpoint:
495
518
  if not session_id:
496
519
  return None
497
520
  record = await self.registry.session_store.get(scoped(session_id))
498
- if record is None or not self.owns(record, principal):
521
+ if record is None or not self.owns(record, principal, request):
499
522
  raise Rejected(Failure(FailureKind.SESSION_NOT_FOUND, "session not found"))
500
523
  await self.registry.session_store.touch(
501
524
  scoped(session_id), ttl_seconds=self.registry.session_ttl_seconds
@@ -578,10 +601,7 @@ class Endpoint:
578
601
  ex.cancel()
579
602
  raise
580
603
  finally:
581
- if not task.done():
582
- task.cancel()
583
- with suppress(asyncio.CancelledError):
584
- await task
604
+ await stop(task)
585
605
 
586
606
 
587
607
  MCP_ENDPOINT = web.AppKey("mcp_endpoint", Endpoint)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import json
6
7
  from collections.abc import Awaitable, Callable, Iterable, Mapping
7
8
  from copy import deepcopy
@@ -57,7 +58,7 @@ class ExtensionSpec:
57
58
  methods: Mapping[str, Bound]
58
59
  #: Broadcast method to the params field that carries its topic, or None for one
59
60
  #: every listener of the method gets.
60
- notifications: Mapping[str, str | None] = MappingProxyType({})
61
+ notifications: Mapping[str, str | None] = dataclasses.field(default_factory=dict)
61
62
 
62
63
 
63
64
  class ExtensionResult(ResultModel):
@@ -6,13 +6,13 @@ import asyncio
6
6
  import json
7
7
  import logging
8
8
  from collections.abc import Coroutine
9
- from contextlib import suppress
10
9
  from dataclasses import replace
11
10
  from typing import Any
12
11
 
13
12
  from aiohttp import web
14
13
 
15
14
  from .adapter import Adapter
15
+ from .auth import Principal, Unauthorized
16
16
  from .core import (
17
17
  Call,
18
18
  DecodeFailure,
@@ -41,6 +41,7 @@ from .sessions import (
41
41
  )
42
42
  from .sse import SSEResponse
43
43
  from .subscriptions import relays, wanted
44
+ from .tasks import stop
44
45
 
45
46
  log = logging.getLogger(__name__)
46
47
 
@@ -100,10 +101,14 @@ class SseEndpoint:
100
101
  """Open a stream and send its POST address."""
101
102
  try:
102
103
  self.origins.check_origin(request)
104
+ principal = await self.origins.verified(request)
103
105
  except Rejected as e:
104
106
  return self.origins.render_failure(self.adapter, e.failure)
107
+ except Unauthorized as refusal:
108
+ assert self.registry.auth is not None
109
+ return self.origins.refuse(refusal)
105
110
 
106
- session_id = await self.open_session()
111
+ session_id = await self.open_session(principal.identity if principal is not None else None)
107
112
  where = topic(STREAM, session_id)
108
113
  hub = self.registry.hub
109
114
  wait = self.registry.hub_poll_seconds
@@ -126,12 +131,12 @@ class SseEndpoint:
126
131
  await self.registry.session_store.delete(scoped(session_id))
127
132
  return response
128
133
 
129
- async def open_session(self) -> str:
134
+ async def open_session(self, owner: str | None = None) -> str:
130
135
  """Create the short-lived session represented by an open stream."""
131
136
  session_id = new_session_id()
132
137
  await self.registry.session_store.create(
133
138
  scoped(session_id),
134
- handshake_data(VERSION, {}),
139
+ handshake_data(VERSION, {}, owner),
135
140
  ttl_seconds=self.registry.session_ttl_seconds,
136
141
  )
137
142
  return session_id
@@ -148,11 +153,7 @@ class SseEndpoint:
148
153
  if transport is None or transport.is_closing():
149
154
  break
150
155
  finally:
151
- for task in tasks:
152
- task.cancel()
153
- for task in tasks:
154
- with suppress(asyncio.CancelledError, ConnectionError):
155
- await task
156
+ await stop(*tasks)
156
157
 
157
158
  async def relay(self, response: SSEResponse, events: Subscription) -> None:
158
159
  """Write everything published for this connection, until cancelled."""
@@ -193,13 +194,20 @@ class SseEndpoint:
193
194
  """Take one message and answer 202. The reply goes to the stream."""
194
195
  try:
195
196
  self.origins.check_origin(request)
197
+ principal = await self.origins.verified(request)
196
198
  except Rejected as e:
197
199
  return self.origins.render_failure(self.adapter, e.failure)
200
+ except Unauthorized as refusal:
201
+ assert self.registry.auth is not None
202
+ return self.origins.refuse(refusal)
198
203
 
199
204
  session_id = request.query.get("session_id", "")
200
205
  record = await self.registry.session_store.get(scoped(session_id)) if session_id else None
201
- if record is None:
206
+ if record is None or not self.origins.owns(record, principal, request):
202
207
  return web.json_response({"error": "no such session"}, status=404)
208
+ await self.registry.session_store.touch(
209
+ scoped(session_id), ttl_seconds=self.registry.session_ttl_seconds
210
+ )
203
211
 
204
212
  raw = await request.read()
205
213
  log.debug("<- [%s] %s", VERSION, raw.decode("utf-8", "replace"))
@@ -214,7 +222,9 @@ class SseEndpoint:
214
222
  return web.Response(status=202)
215
223
 
216
224
  for item in items:
217
- await self.serve(adapter, item, session_id, record, where)
225
+ await self.serve(
226
+ adapter, item, session_id, record, where, request=request, principal=principal
227
+ )
218
228
  return web.Response(status=202)
219
229
 
220
230
  async def serve(
@@ -224,6 +234,9 @@ class SseEndpoint:
224
234
  session_id: str,
225
235
  record: SessionRecord,
226
236
  where: str,
237
+ *,
238
+ request: web.Request,
239
+ principal: Principal | None,
227
240
  ) -> None:
228
241
  hub = self.registry.hub
229
242
  if isinstance(item, DecodeFailure):
@@ -241,7 +254,8 @@ class SseEndpoint:
241
254
  session = Session(
242
255
  self.registry.session_store, session_id, record, self.registry.session_ttl_seconds
243
256
  )
244
- ex = Exchange(self.registry, self.origins, adapter, item, session=session)
257
+ ex = Exchange(self.registry, request, adapter, item, session=session)
258
+ ex.principal = principal
245
259
  ex.send = lambda payload: hub.publish(where, payload)
246
260
 
247
261
  try:
@@ -270,31 +284,48 @@ class SseEndpoint:
270
284
  return path[: -len(self.sse_path)] if path.endswith(self.sse_path) else ""
271
285
 
272
286
  def routes(
273
- self, sse_path: str | None = None, message_path: str | None = None
287
+ self,
288
+ sse_path: str | None = None,
289
+ message_path: str | None = None,
290
+ *,
291
+ metadata: bool = True,
274
292
  ) -> list[web.RouteDef]:
275
293
  """One route to listen on, one to post to.
276
294
 
277
295
  Either path may be set here or on the constructor. Both are kept,
278
296
  because the stream names the posting path to the client.
297
+
298
+ Policies that declare metadata include its routes. Use `metadata=False`
299
+ when another endpoint serves it or when mounting under a subapplication.
300
+ In a subapplication, add `metadata_routes()` to the root application.
279
301
  """
280
302
  if sse_path is not None:
281
303
  self.sse_path = sse_path
282
304
  if message_path is not None:
283
305
  self.message_path = message_path
284
306
  log.debug("HTTP+SSE stream at %s, messages at %s", self.sse_path, self.message_path)
285
- return [
307
+ found = [
286
308
  web.get(self.sse_path, self.listen),
287
309
  web.post(self.message_path, self.receive),
288
310
  ]
311
+ if metadata:
312
+ found.extend(self.metadata_routes())
313
+ return found
314
+
315
+ def metadata_routes(self, *, name: str | None = "mcp-sse") -> list[web.RouteDef]:
316
+ """Return protected-resource metadata routes for the root application."""
317
+ return self.origins.metadata_routes(name=name)
289
318
 
290
319
  def setup(
291
320
  self,
292
321
  app: web.Application,
293
322
  sse_path: str | None = None,
294
323
  message_path: str | None = None,
324
+ *,
325
+ metadata: bool = True,
295
326
  ) -> web.Application:
296
327
  log.debug("adding the HTTP+SSE routes to %r", app)
297
- app.add_routes(self.routes(sse_path, message_path))
328
+ app.add_routes(self.routes(sse_path, message_path, metadata=metadata))
298
329
  return app
299
330
 
300
331
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Awaitable, Callable, Mapping
5
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
6
6
  from typing import Any
7
7
 
8
- from .auth import Authorization, Principal
8
+ from .auth import Authentication, Principal
9
9
  from .exchange import Exchange, Instance
10
10
  from .extensions import Extension, ExtensionSpec
11
11
  from .hub import NOTIFICATIONS, Cursor, Hub, MemoryHub, topic
@@ -36,7 +36,7 @@ class Registry:
36
36
  *,
37
37
  hub: Hub | None = None,
38
38
  session_store: SessionStore | None = None,
39
- auth: Authorization | None = None,
39
+ auth: Authentication | Iterable[Authentication] | None = None,
40
40
  instructions: str | None = None,
41
41
  session_ttl_seconds: int = DEFAULT_TTL_SECONDS,
42
42
  request_state_ttl_seconds: int = STATE_TTL_SECONDS,
@@ -64,6 +64,30 @@ class Registry:
64
64
  self.extensions: dict[str, ExtensionSpec] = {}
65
65
  self.resource_aliases: dict[str, ResourceSpec] = {}
66
66
 
67
+ @property
68
+ def auth(self) -> Authentication | tuple[Authentication, ...] | None:
69
+ """Configured authentication policies. Iterables are consumed once."""
70
+ return self._auth
71
+
72
+ @auth.setter
73
+ def auth(self, value: Authentication | Iterable[Authentication] | None) -> None:
74
+ if value is None or isinstance(value, Authentication):
75
+ self._auth = value
76
+ return
77
+ policies = tuple(value)
78
+ if not policies or not all(isinstance(policy, Authentication) for policy in policies):
79
+ raise ValueError("auth must contain at least one Authentication policy")
80
+ self._auth = policies
81
+
82
+ @property
83
+ def auth_policies(self) -> tuple[Authentication, ...]:
84
+ """Authentication alternatives in registration order."""
85
+ if self._auth is None:
86
+ return ()
87
+ if isinstance(self._auth, Authentication):
88
+ return (self._auth,)
89
+ return self._auth
90
+
67
91
  def extension(self, extension: Extension) -> None:
68
92
  """Install an extension and its resources after checking all declarations.
69
93
 
@@ -205,7 +229,7 @@ class Registry:
205
229
  if self.auth is None:
206
230
  raise TypeError(
207
231
  f"{what}: parameter {name!r} wants a Principal, but this registry "
208
- f"has no auth= -- pass an Authorization to verify tokens first"
232
+ f"has no auth= -- pass an Authentication policy to verify callers first"
209
233
  )
210
234
  continue
211
235
  if kind not in self.providers and kind is not Exchange:
@@ -11,6 +11,7 @@ import json
11
11
  import logging
12
12
  import sys
13
13
  from collections.abc import Callable, Mapping
14
+ from contextlib import suppress
14
15
  from dataclasses import replace
15
16
  from typing import Any
16
17
 
@@ -26,6 +27,7 @@ from .dispatcher import Dispatcher
26
27
  from .exchange import Exchange, is_reply, relay_reply
27
28
  from .protocol.selection import AdapterSet
28
29
  from .registry import Registry
30
+ from .tasks import stop
29
31
 
30
32
  log = logging.getLogger("aiohttp_tiny_mcp")
31
33
 
@@ -155,9 +157,9 @@ async def serve_stdio(
155
157
  tasks = []
156
158
  for exchange, task in list(active.values()):
157
159
  exchange.cancel()
158
- task.cancel()
159
160
  tasks.append(task)
160
- await asyncio.gather(*tasks, return_exceptions=True)
161
+ with suppress(Exception):
162
+ await stop(*tasks)
161
163
 
162
164
 
163
165
  async def stdin_reader() -> asyncio.StreamReader:
@@ -0,0 +1,37 @@
1
+ """Ending a task that may not honour its first cancellation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ STOP_GRACE_SECONDS = 1.0
9
+
10
+
11
+ async def stop(*tasks: asyncio.Task[Any], grace: float = STOP_GRACE_SECONDS) -> None:
12
+ """Cancel `tasks` and return when every one of them has ended.
13
+
14
+ One cancellation is not always enough. A library may catch it, and on
15
+ Python before 3.12 `asyncio.wait_for` drops it when the future it waits
16
+ on completes in the same loop turn: psycopg_pool waits for a connection
17
+ that way, and a sibling task that is cancelled first hands its
18
+ connection back at exactly that moment. A task still running after
19
+ `grace` seconds is cancelled again, at whatever it awaits by then.
20
+
21
+ A cancelled task and one that ended with `ConnectionError` are what
22
+ stopping looks like. Any other exception is raised, the first one found.
23
+ """
24
+ pending = {task for task in tasks if not task.done()}
25
+ while pending:
26
+ for task in pending:
27
+ task.cancel()
28
+ _, pending = await asyncio.wait(pending, timeout=grace)
29
+ for task in tasks:
30
+ if task.cancelled():
31
+ continue
32
+ error = task.exception()
33
+ if error is not None and not isinstance(error, ConnectionError):
34
+ raise error
35
+
36
+
37
+ __all__ = ["STOP_GRACE_SECONDS", "stop"]
@@ -1,129 +0,0 @@
1
- """Bearer-token verification for an OAuth protected MCP resource."""
2
-
3
- from __future__ import annotations
4
-
5
- import time
6
- from collections.abc import Iterable, Mapping, Sequence
7
- from dataclasses import dataclass, field
8
- from typing import Any, Protocol, runtime_checkable
9
- from urllib.parse import urlsplit
10
-
11
- WELL_KNOWN = "/.well-known/oauth-protected-resource"
12
-
13
-
14
- @dataclass(frozen=True, slots=True)
15
- class Principal:
16
- """Identity and claims returned by a token verifier."""
17
-
18
- subject: str | None = None
19
- client_id: str = ""
20
- issuer: str | None = None
21
- scopes: frozenset[str] = frozenset()
22
- expires_at: float | None = None
23
- claims: Mapping[str, Any] = field(default_factory=dict)
24
-
25
- def holds(self, wanted: Iterable[str]) -> frozenset[str]:
26
- """Return required scopes absent from this principal."""
27
- return frozenset(wanted) - self.scopes
28
-
29
- @property
30
- def expired(self) -> bool:
31
- return self.expires_at is not None and self.expires_at < time.time()
32
-
33
- @property
34
- def identity(self) -> str:
35
- """Return an issuer-qualified subject or client identifier."""
36
- if self.subject:
37
- return f"{self.issuer or ''}|{self.subject}"
38
- return f"{self.issuer or ''}|client:{self.client_id}"
39
-
40
-
41
- @runtime_checkable
42
- class TokenVerifier(Protocol):
43
- """Verify a bearer token for this resource."""
44
-
45
- async def verify(self, token: str) -> Principal | None: ...
46
-
47
-
48
- class Unauthorized(Exception):
49
- """Authentication or authorization failed."""
50
-
51
- def __init__(self, error: str, description: str, status: int = 401) -> None:
52
- super().__init__(description)
53
- self.error = error
54
- self.description = description
55
- self.status = status
56
-
57
-
58
- @dataclass(frozen=True)
59
- class Authorization:
60
- """Configuration for an OAuth protected resource server."""
61
-
62
- verifier: TokenVerifier
63
- resource: str
64
- authorization_servers: Sequence[str] = ()
65
- scopes_supported: Sequence[str] | None = None
66
- required_scopes: Sequence[str] = ()
67
- resource_name: str | None = None
68
- documentation: str | None = None
69
- bind_sessions: bool = True
70
- namespace_from_token: bool = True
71
-
72
- @property
73
- def metadata_path(self) -> str:
74
- """Return this resource's RFC 9728 metadata path."""
75
- path = urlsplit(self.resource).path.rstrip("/")
76
- return f"{WELL_KNOWN}{path}"
77
-
78
- def metadata(self) -> dict[str, Any]:
79
- """Build this resource's RFC 9728 metadata document."""
80
- found: dict[str, Any] = {"resource": self.resource}
81
- if self.authorization_servers:
82
- found["authorization_servers"] = list(self.authorization_servers)
83
- if self.scopes_supported is not None:
84
- found["scopes_supported"] = list(self.scopes_supported)
85
- if self.resource_name:
86
- found["resource_name"] = self.resource_name
87
- if self.documentation:
88
- found["resource_documentation"] = self.documentation
89
- found["bearer_methods_supported"] = ["header"]
90
- return found
91
-
92
- def challenge(self, refusal: Unauthorized) -> str:
93
- """Build a Bearer challenge with a metadata URL."""
94
- parts = [
95
- f'error="{refusal.error}"',
96
- f'error_description="{refusal.description}"',
97
- f'resource_metadata="{self.metadata_url}"',
98
- ]
99
- return "Bearer " + ", ".join(parts)
100
-
101
- @property
102
- def metadata_url(self) -> str:
103
- split = urlsplit(self.resource)
104
- return f"{split.scheme}://{split.netloc}{self.metadata_path}"
105
-
106
- async def principal(self, authorization: str | None) -> Principal:
107
- """Verify an Authorization header and return its principal."""
108
- if not authorization or not authorization.lower().startswith("bearer "):
109
- raise Unauthorized("invalid_request", "authorization required")
110
- found = await self.verifier.verify(authorization[len("bearer ") :].strip())
111
- if found is None or found.expired:
112
- raise Unauthorized("invalid_token", "the token is not valid for this resource")
113
- missing = found.holds(self.required_scopes)
114
- if missing:
115
- raise Unauthorized(
116
- "insufficient_scope",
117
- f"missing scope: {', '.join(sorted(missing))}",
118
- status=403,
119
- )
120
- return found
121
-
122
-
123
- __all__ = [
124
- "WELL_KNOWN",
125
- "Authorization",
126
- "Principal",
127
- "TokenVerifier",
128
- "Unauthorized",
129
- ]