aiohttp-mcp-server 0.1.0__py3-none-any.whl
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.
- aiohttp_mcp_server/__init__.py +249 -0
- aiohttp_mcp_server/_auth.py +291 -0
- aiohttp_mcp_server/_auth_server.py +1021 -0
- aiohttp_mcp_server/_content.py +252 -0
- aiohttp_mcp_server/_elicitation.py +656 -0
- aiohttp_mcp_server/_handlers.py +132 -0
- aiohttp_mcp_server/_prompts.py +211 -0
- aiohttp_mcp_server/_protocol.py +592 -0
- aiohttp_mcp_server/_resources.py +442 -0
- aiohttp_mcp_server/_secure_form.py +223 -0
- aiohttp_mcp_server/_server.py +251 -0
- aiohttp_mcp_server/_tools.py +666 -0
- aiohttp_mcp_server/_transport.py +506 -0
- aiohttp_mcp_server/_validators.py +207 -0
- aiohttp_mcp_server/jwt.py +160 -0
- aiohttp_mcp_server/py.typed +0 -0
- aiohttp_mcp_server-0.1.0.dist-info/METADATA +102 -0
- aiohttp_mcp_server-0.1.0.dist-info/RECORD +21 -0
- aiohttp_mcp_server-0.1.0.dist-info/WHEEL +5 -0
- aiohttp_mcp_server-0.1.0.dist-info/licenses/LICENSE +201 -0
- aiohttp_mcp_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) server support for aiohttp."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Collection
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from aiohttp import web
|
|
7
|
+
from yarl import URL
|
|
8
|
+
|
|
9
|
+
from ._auth import TOKEN_INFO_KEY, Authorization, ScopeMatcher, TokenInfo, TokenValidator
|
|
10
|
+
from ._auth_server import (
|
|
11
|
+
AuthenticateRequest,
|
|
12
|
+
CheckCredentials,
|
|
13
|
+
ConsentContext,
|
|
14
|
+
LocalAuthorization,
|
|
15
|
+
RenderConsent,
|
|
16
|
+
)
|
|
17
|
+
from ._content import (
|
|
18
|
+
AudioContent,
|
|
19
|
+
Content,
|
|
20
|
+
EmbeddedResource,
|
|
21
|
+
Icon,
|
|
22
|
+
ImageContent,
|
|
23
|
+
ResourceLink,
|
|
24
|
+
TextContent,
|
|
25
|
+
)
|
|
26
|
+
from ._elicitation import (
|
|
27
|
+
BooleanSchema,
|
|
28
|
+
Elicitation,
|
|
29
|
+
ElicitResult,
|
|
30
|
+
EnumSchema,
|
|
31
|
+
FieldSchema,
|
|
32
|
+
FormElicitation,
|
|
33
|
+
InputRequired,
|
|
34
|
+
IntegerSchema,
|
|
35
|
+
MultiSelectEnumSchema,
|
|
36
|
+
NumberSchema,
|
|
37
|
+
SecureFormElicitation,
|
|
38
|
+
StringSchema,
|
|
39
|
+
URLElicitation,
|
|
40
|
+
)
|
|
41
|
+
from ._handlers import Doc, Header
|
|
42
|
+
from ._prompts import Message, Prompt, PromptHandler, PromptRegistry, PromptResult
|
|
43
|
+
from ._protocol import PROTOCOL_VERSIONS
|
|
44
|
+
from ._resources import (
|
|
45
|
+
Resource,
|
|
46
|
+
ResourceContents,
|
|
47
|
+
ResourceHandler,
|
|
48
|
+
ResourceRegistry,
|
|
49
|
+
ResourceResult,
|
|
50
|
+
)
|
|
51
|
+
from ._secure_form import FormContext, RenderForm
|
|
52
|
+
from ._server import MCPServer
|
|
53
|
+
from ._tools import RateLimiter, Tool, ToolError, ToolHandler, ToolRegistry, ToolResult
|
|
54
|
+
from ._transport import MCPRequest
|
|
55
|
+
from ._validators import IntrospectionAuthorization
|
|
56
|
+
|
|
57
|
+
__version__ = "0.1.0"
|
|
58
|
+
|
|
59
|
+
# https://github.com/sphinx-doc/sphinx/issues/6495
|
|
60
|
+
#: Protocol revisions implemented, newest first.
|
|
61
|
+
PROTOCOL_VERSIONS: tuple[str, ...] # type: ignore[no-redef]
|
|
62
|
+
#: The :class:`~aiohttp.web.Request` key under which the validated
|
|
63
|
+
#: :class:`TokenInfo` is stored for authenticated requests.
|
|
64
|
+
TOKEN_INFO_KEY: web.RequestKey[TokenInfo] # type: ignore[no-redef]
|
|
65
|
+
|
|
66
|
+
__all__ = (
|
|
67
|
+
"PROTOCOL_VERSIONS",
|
|
68
|
+
"TOKEN_INFO_KEY",
|
|
69
|
+
"AudioContent",
|
|
70
|
+
"AuthenticateRequest",
|
|
71
|
+
"Authorization",
|
|
72
|
+
"BooleanSchema",
|
|
73
|
+
"CheckCredentials",
|
|
74
|
+
"ConsentContext",
|
|
75
|
+
"Content",
|
|
76
|
+
"Doc",
|
|
77
|
+
"ElicitResult",
|
|
78
|
+
"Elicitation",
|
|
79
|
+
"EmbeddedResource",
|
|
80
|
+
"EnumSchema",
|
|
81
|
+
"FieldSchema",
|
|
82
|
+
"FormContext",
|
|
83
|
+
"FormElicitation",
|
|
84
|
+
"Header",
|
|
85
|
+
"Icon",
|
|
86
|
+
"ImageContent",
|
|
87
|
+
"InputRequired",
|
|
88
|
+
"IntegerSchema",
|
|
89
|
+
"IntrospectionAuthorization",
|
|
90
|
+
"LocalAuthorization",
|
|
91
|
+
"MCPRequest",
|
|
92
|
+
"MCPServer",
|
|
93
|
+
"Message",
|
|
94
|
+
"MultiSelectEnumSchema",
|
|
95
|
+
"NumberSchema",
|
|
96
|
+
"Prompt",
|
|
97
|
+
"PromptHandler",
|
|
98
|
+
"PromptRegistry",
|
|
99
|
+
"PromptResult",
|
|
100
|
+
"RateLimiter",
|
|
101
|
+
"RenderConsent",
|
|
102
|
+
"RenderForm",
|
|
103
|
+
"Resource",
|
|
104
|
+
"ResourceContents",
|
|
105
|
+
"ResourceHandler",
|
|
106
|
+
"ResourceLink",
|
|
107
|
+
"ResourceRegistry",
|
|
108
|
+
"ResourceResult",
|
|
109
|
+
"ScopeMatcher",
|
|
110
|
+
"SecureFormElicitation",
|
|
111
|
+
"StringSchema",
|
|
112
|
+
"TextContent",
|
|
113
|
+
"TokenInfo",
|
|
114
|
+
"TokenValidator",
|
|
115
|
+
"Tool",
|
|
116
|
+
"ToolError",
|
|
117
|
+
"ToolHandler",
|
|
118
|
+
"ToolRegistry",
|
|
119
|
+
"ToolResult",
|
|
120
|
+
"URLElicitation",
|
|
121
|
+
"setup",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def setup(
|
|
126
|
+
app: web.Application,
|
|
127
|
+
*,
|
|
128
|
+
name: str = "aiohttp-mcp-server",
|
|
129
|
+
version: str = __version__,
|
|
130
|
+
path: str = "/mcp",
|
|
131
|
+
title: str | None = None,
|
|
132
|
+
description: str | None = None,
|
|
133
|
+
website_url: str | None = None,
|
|
134
|
+
instructions: str | None = None,
|
|
135
|
+
include_server_info: bool = True,
|
|
136
|
+
discover_ttl: float = 3600.0,
|
|
137
|
+
discover_cache_scope: Literal["public", "private"] = "public",
|
|
138
|
+
allowed_origins: Collection[str] | Callable[[str], bool] | None = None,
|
|
139
|
+
sse_keepalive: float = 15.0,
|
|
140
|
+
authorization: Authorization | None = None,
|
|
141
|
+
well_known_routes: bool = True,
|
|
142
|
+
prompts: PromptRegistry | None = None,
|
|
143
|
+
resources: ResourceRegistry | None = None,
|
|
144
|
+
tools: ToolRegistry | None = None,
|
|
145
|
+
state_secret: bytes | None = None,
|
|
146
|
+
authenticate_request: AuthenticateRequest | None = None,
|
|
147
|
+
render_form: RenderForm | None = None,
|
|
148
|
+
) -> MCPServer:
|
|
149
|
+
"""Serve an MCP endpoint at ``path`` on ``app``.
|
|
150
|
+
|
|
151
|
+
:param name: Server name, reported as ``serverInfo`` and by ``server/discover``.
|
|
152
|
+
:param version: Version of the server implementation.
|
|
153
|
+
:param path: path for the MCP endpoint.
|
|
154
|
+
:param title: Optional human-readable display name.
|
|
155
|
+
:param description: Optional human-readable description of the server.
|
|
156
|
+
:param website_url: Optional URL of the server's website.
|
|
157
|
+
:param instructions: Optional natural-language guidance for LLMs, returned
|
|
158
|
+
by ``server/discover``. For most harnesses, these instructions and a list
|
|
159
|
+
of tool names is likely the only thing the LLM will see at session start.
|
|
160
|
+
:param include_server_info: Include ``serverInfo`` in every result's
|
|
161
|
+
``_meta`` (recommended by the specification).
|
|
162
|
+
:param discover_ttl: Client-side cache lifetime of the ``server/discover``
|
|
163
|
+
result, in seconds.
|
|
164
|
+
:param discover_cache_scope: Whether shared intermediaries may cache the
|
|
165
|
+
``server/discover`` result (``"public"``) or only the requesting
|
|
166
|
+
client (``"private"``).
|
|
167
|
+
:param allowed_origins: Hosts allowed to appear in the ``Origin`` header
|
|
168
|
+
(compared against the header's parsed host), or a callable receiving
|
|
169
|
+
that host and replacing the policy. Defaults to the host of
|
|
170
|
+
``authorization``'s resource when authorization is configured, and to
|
|
171
|
+
``localhost`` otherwise. Behind a reverse proxy,
|
|
172
|
+
make sure the forwarded scheme and host are applied to requests
|
|
173
|
+
(e.g. with :mod:`aiohttp_remotes`) so built URLs are correct.
|
|
174
|
+
:param sse_keepalive: Interval in seconds between SSE keep-alive comments
|
|
175
|
+
on response streams; also bounds how quickly a client disconnect is
|
|
176
|
+
detected.
|
|
177
|
+
:param authorization: Enable authorization using :brand:`OAuth` tokens.
|
|
178
|
+
:param well_known_routes: Register the well-known discovery
|
|
179
|
+
documents on ``app``. Set to :data:`False` when ``app`` is mounted as a
|
|
180
|
+
sub-application - those documents must be served from the origin
|
|
181
|
+
root, so call :meth:`Authorization.register_well_known` on the
|
|
182
|
+
root application instead.
|
|
183
|
+
:param prompts: The registry of prompts to serve. Enables the ``prompts`` capability.
|
|
184
|
+
:param resources: The registry of resources to serve. Enables the
|
|
185
|
+
``resources`` capability, including per-resource update notifications
|
|
186
|
+
via :meth:`.MCPServer.resource_updated`.
|
|
187
|
+
:param tools: The registry of tools to serve. Enables the ``tools`` capability.
|
|
188
|
+
:param state_secret: Key used for signing when using :meth:`.MCPRequest.elicit`.
|
|
189
|
+
Random when omitted, which invalidates in-flight requests on restart and
|
|
190
|
+
breaks them across workers.
|
|
191
|
+
:param authenticate_request: Returns the authenticated browser user for a
|
|
192
|
+
request, enabling :class:`SecureFormElicitation` pages to verify who
|
|
193
|
+
opened them. Not needed with :class:`LocalAuthorization`, whose login
|
|
194
|
+
session is used by default.
|
|
195
|
+
:param render_form: Replaces the hosted secure-form page. Receives the
|
|
196
|
+
request and a :class:`FormContext`. The page it returns must satisfy
|
|
197
|
+
the form contract documented there.
|
|
198
|
+
"""
|
|
199
|
+
if authorization is not None:
|
|
200
|
+
authorization._bind(path)
|
|
201
|
+
if allowed_origins is None:
|
|
202
|
+
if authorization is not None:
|
|
203
|
+
host = URL(authorization._resource).host
|
|
204
|
+
assert host is not None # enforced by Authorization
|
|
205
|
+
allowed_origins = (host,)
|
|
206
|
+
else:
|
|
207
|
+
allowed_origins = ("localhost", "127.0.0.1", "::1")
|
|
208
|
+
server = MCPServer(
|
|
209
|
+
name=name,
|
|
210
|
+
version=version,
|
|
211
|
+
title=title,
|
|
212
|
+
description=description,
|
|
213
|
+
website_url=website_url,
|
|
214
|
+
instructions=instructions,
|
|
215
|
+
include_server_info=include_server_info,
|
|
216
|
+
discover_ttl=discover_ttl,
|
|
217
|
+
discover_cache_scope=discover_cache_scope,
|
|
218
|
+
allowed_origins=allowed_origins,
|
|
219
|
+
sse_keepalive=sse_keepalive,
|
|
220
|
+
authorization=authorization,
|
|
221
|
+
prompts=prompts,
|
|
222
|
+
resources=resources,
|
|
223
|
+
tools=tools,
|
|
224
|
+
state_secret=state_secret,
|
|
225
|
+
)
|
|
226
|
+
app.router.add_post(path, server._handle)
|
|
227
|
+
if authorization is not None:
|
|
228
|
+
authorization._register_routes(app)
|
|
229
|
+
if well_known_routes:
|
|
230
|
+
authorization.register_well_known(app)
|
|
231
|
+
|
|
232
|
+
# Secure-form elicitation pages, when a browser identity is available.
|
|
233
|
+
form_identity = authenticate_request
|
|
234
|
+
if isinstance(authorization, LocalAuthorization):
|
|
235
|
+
if form_identity is None:
|
|
236
|
+
form_identity = authorization._subject
|
|
237
|
+
server._form_login = lambda r: authorization._login_redirect(r.rel_url)
|
|
238
|
+
if authorization is not None and form_identity is not None:
|
|
239
|
+
server._form_identity = form_identity
|
|
240
|
+
if render_form is not None:
|
|
241
|
+
server._form_render = render_form
|
|
242
|
+
resource = app.router.add_resource(f"{path}/elicit/{{ticket}}")
|
|
243
|
+
resource.add_route("GET", server._handle_form)
|
|
244
|
+
resource.add_route("POST", server._handle_form)
|
|
245
|
+
server._form_resource = resource
|
|
246
|
+
if isinstance(authorization, LocalAuthorization):
|
|
247
|
+
authorization._extra_return_prefixes.append(lambda: str(resource.url_for(ticket="")))
|
|
248
|
+
app.on_shutdown.append(server._shutdown)
|
|
249
|
+
return server
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Authorization: OAuth 2.1 resource-server behaviour for the MCP endpoint."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Awaitable, Callable, Collection, Iterable, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Final, NoReturn, NotRequired, TypedDict
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 12):
|
|
10
|
+
from typing import TypeAliasType
|
|
11
|
+
else:
|
|
12
|
+
from typing_extensions import TypeAliasType
|
|
13
|
+
|
|
14
|
+
from aiohttp import hdrs, web
|
|
15
|
+
from yarl import URL
|
|
16
|
+
|
|
17
|
+
from ._protocol import INVALID_REQUEST, MCPError, error_envelope
|
|
18
|
+
|
|
19
|
+
# https://datatracker.ietf.org/doc/html/rfc9728#name-well-known-uri-registry
|
|
20
|
+
_WELL_KNOWN: Final = "/.well-known/oauth-protected-resource"
|
|
21
|
+
|
|
22
|
+
# b64token syntax of RFC 6750 section 2.1 (token68 of RFC 9110).
|
|
23
|
+
_B64TOKEN: Final = re.compile(r"[A-Za-z0-9\-._~+/]+=*")
|
|
24
|
+
# scope-token syntax of RFC 6749 section 3.3.
|
|
25
|
+
_SCOPE_TOKEN: Final = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+")
|
|
26
|
+
|
|
27
|
+
_LOOPBACK: Final = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _secure_url(url: URL) -> bool:
|
|
31
|
+
return url.scheme == "https" or (url.scheme == "http" and url.host in _LOOPBACK)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class TokenInfo:
|
|
37
|
+
"""Validated access-token information produced by a :data:`TokenValidator`."""
|
|
38
|
+
|
|
39
|
+
#: The scopes granted to the token, from the ``scope`` claim.
|
|
40
|
+
scopes: frozenset[str] = frozenset()
|
|
41
|
+
#: Who the token was issued to (the ``sub`` claim), when known.
|
|
42
|
+
subject: str | None = None
|
|
43
|
+
#: All claims returned by the validator, for application-specific checks
|
|
44
|
+
#: (e.g. ``email``); keys are validator-dependent.
|
|
45
|
+
claims: Mapping[str, object] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# TODO(PY312): use the `type` statement.
|
|
49
|
+
ScopeMatcher = TypeAliasType("ScopeMatcher", Callable[[str, frozenset[str]], bool])
|
|
50
|
+
TokenValidator = TypeAliasType(
|
|
51
|
+
"TokenValidator", Callable[[str, web.Request], Awaitable["TokenInfo | None"]]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
TOKEN_INFO_KEY: Final = web.RequestKey("TOKEN_INFO_KEY", TokenInfo)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# TODO(PY315): closed=True
|
|
58
|
+
class ProtectedResourceMetadata(TypedDict):
|
|
59
|
+
"""The RFC 9728 metadata subset emitted by this library."""
|
|
60
|
+
|
|
61
|
+
resource: str
|
|
62
|
+
authorization_servers: Sequence[str]
|
|
63
|
+
bearer_methods_supported: Sequence[str]
|
|
64
|
+
scopes_supported: NotRequired[Sequence[str]]
|
|
65
|
+
resource_name: NotRequired[str]
|
|
66
|
+
resource_documentation: NotRequired[str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _AuthError(Exception):
|
|
70
|
+
"""Carries the finished HTTP response for a failed authentication."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, response: web.Response) -> None:
|
|
73
|
+
super().__init__(response.reason)
|
|
74
|
+
self.response = response
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Authorization:
|
|
78
|
+
"""Authorization configuration for an MCP endpoint.
|
|
79
|
+
|
|
80
|
+
:param validator: Asynchronous callable validating a presented bearer token. The
|
|
81
|
+
callback will receive the token and the request and must return a
|
|
82
|
+
:class:`TokenInfo` object or :data:`None` if the token is not valid.
|
|
83
|
+
:param resource: Base URL this MCP server is reachable at: scheme and
|
|
84
|
+
host (and optional port), plus the mount prefix when served from a
|
|
85
|
+
sub-application — e.g. ``"https://mcp.mydomain.example"`` or
|
|
86
|
+
``"https://mcp.mydomain.example/api"``. Must be absolute ``http(s)``,
|
|
87
|
+
canonical, without query or fragment. The canonical resource
|
|
88
|
+
identifier that clients request tokens for is completed by
|
|
89
|
+
:func:`~aiohttp_mcp_server.setup`, which appends the MCP endpoint path — so
|
|
90
|
+
the identifier always matches the endpoint it is served on.
|
|
91
|
+
:param auth_servers: Issuer identifiers of at least one authorization
|
|
92
|
+
server from which clients can obtain access tokens.
|
|
93
|
+
:param default_scopes: The initial set of scopes advertised to a client.
|
|
94
|
+
Generally a client will start by requesting an access token with all
|
|
95
|
+
of these scopes. It is recommended to advertise only enough scopes for
|
|
96
|
+
basic functionality. Clients will re-authenticate to acquire new scopes
|
|
97
|
+
when needed for additional functionality.
|
|
98
|
+
:param scope_matcher: A callback which receives the requested scope and the
|
|
99
|
+
user's current scopes and returns True if the requested scope is
|
|
100
|
+
allowed, False otherwise. This can be used to implement scope hierarchy.
|
|
101
|
+
Defaults to an exact match (:code:`requested in user_scopes`).
|
|
102
|
+
:param resource_name: Optional human-readable name for display purposes.
|
|
103
|
+
:param resource_documentation: Optional URL of developer documentation.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
_resource: str
|
|
107
|
+
_metadata_path: str
|
|
108
|
+
_metadata_url: str
|
|
109
|
+
_metadata: ProtectedResourceMetadata
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
validator: TokenValidator,
|
|
114
|
+
*,
|
|
115
|
+
resource: str,
|
|
116
|
+
auth_servers: Sequence[str],
|
|
117
|
+
default_scopes: Iterable[str] = (),
|
|
118
|
+
scope_matcher: ScopeMatcher | None = None,
|
|
119
|
+
resource_name: str | None = None,
|
|
120
|
+
resource_documentation: str | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#canonical-server-uri
|
|
123
|
+
resource = resource.rstrip("/")
|
|
124
|
+
resource_url = URL(resource)
|
|
125
|
+
if (
|
|
126
|
+
not resource_url.is_absolute()
|
|
127
|
+
or resource_url.scheme not in {"http", "https"}
|
|
128
|
+
or resource_url.host is None
|
|
129
|
+
or resource_url.query_string
|
|
130
|
+
or resource_url.fragment
|
|
131
|
+
):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"resource must be an absolute http(s) URL without query or fragment"
|
|
134
|
+
)
|
|
135
|
+
if str(resource_url) != resource:
|
|
136
|
+
raise ValueError(f"resource must be in canonical form: {str(resource_url)!r}")
|
|
137
|
+
if "%" in resource:
|
|
138
|
+
raise ValueError("percent-encoded resource paths are not supported")
|
|
139
|
+
self._origin: Final = str(resource_url.origin())
|
|
140
|
+
if not resource.startswith(self._origin):
|
|
141
|
+
# e.g. userinfo, which origin() strips.
|
|
142
|
+
raise ValueError("resource must not contain userinfo")
|
|
143
|
+
if not auth_servers:
|
|
144
|
+
raise ValueError("at least one authorization server is required")
|
|
145
|
+
for issuer in auth_servers:
|
|
146
|
+
issuer_url = URL(issuer)
|
|
147
|
+
if not issuer_url.is_absolute() or not _secure_url(issuer_url):
|
|
148
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#communication-security
|
|
149
|
+
raise ValueError(f"authorization server must be an HTTPS URL: {issuer!r}")
|
|
150
|
+
|
|
151
|
+
self._default_scopes = tuple(default_scopes)
|
|
152
|
+
for scope in self._default_scopes:
|
|
153
|
+
if not _SCOPE_TOKEN.fullmatch(scope):
|
|
154
|
+
raise ValueError(f"invalid scope token: {scope!r}")
|
|
155
|
+
|
|
156
|
+
self._validator = validator
|
|
157
|
+
self._base: Final = resource
|
|
158
|
+
self._scope_matcher: ScopeMatcher = (
|
|
159
|
+
scope_matcher if scope_matcher is not None else lambda s, scopes: s in scopes
|
|
160
|
+
)
|
|
161
|
+
self._auth_servers = tuple(auth_servers)
|
|
162
|
+
self._resource_name = resource_name
|
|
163
|
+
self._resource_documentation = resource_documentation
|
|
164
|
+
|
|
165
|
+
def _bind(self, path: str) -> None:
|
|
166
|
+
"""Complete the resource identifier with the MCP endpoint ``path``."""
|
|
167
|
+
resource = self._base + (path.rstrip("/") if path != "/" else "")
|
|
168
|
+
if hasattr(self, "_resource"):
|
|
169
|
+
if self._resource != resource:
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f"authorization is already bound to {self._resource!r}; "
|
|
172
|
+
"use a separate Authorization instance per endpoint"
|
|
173
|
+
)
|
|
174
|
+
return
|
|
175
|
+
if str(URL(resource)) != resource or "%" in resource:
|
|
176
|
+
raise ValueError(f"endpoint path is not canonical: {path!r}")
|
|
177
|
+
self._resource = resource
|
|
178
|
+
|
|
179
|
+
# RFC 9728 path-insertion relative to the resource identifier:
|
|
180
|
+
# https://mcp.example.com/mcp -> /.well-known/oauth-protected-resource/mcp
|
|
181
|
+
self._metadata_path = _WELL_KNOWN + resource[len(self._origin):]
|
|
182
|
+
self._metadata_url = self._origin + self._metadata_path
|
|
183
|
+
|
|
184
|
+
self._metadata = {
|
|
185
|
+
"resource": self._resource,
|
|
186
|
+
"authorization_servers": self._auth_servers,
|
|
187
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-requirements
|
|
188
|
+
"bearer_methods_supported": ("header",),
|
|
189
|
+
}
|
|
190
|
+
if self._default_scopes:
|
|
191
|
+
self._metadata["scopes_supported"] = self._default_scopes
|
|
192
|
+
if self._resource_name is not None:
|
|
193
|
+
self._metadata["resource_name"] = self._resource_name
|
|
194
|
+
if self._resource_documentation is not None:
|
|
195
|
+
self._metadata["resource_documentation"] = self._resource_documentation
|
|
196
|
+
|
|
197
|
+
def _register_routes(self, app: web.Application) -> None:
|
|
198
|
+
"""Register any routes and lifecycle hooks on app."""
|
|
199
|
+
|
|
200
|
+
def register_well_known(self, app: web.Application) -> None:
|
|
201
|
+
"""Register the well-known discovery documents.
|
|
202
|
+
|
|
203
|
+
These must be served from the origin root (:rfc:`9728`/:rfc:`8414`
|
|
204
|
+
path-inserted well-known URIs): call on the root application.
|
|
205
|
+
"""
|
|
206
|
+
if not hasattr(self, "_resource"):
|
|
207
|
+
raise RuntimeError("setup() must be called before register_well_known()")
|
|
208
|
+
app.router.add_get(self._metadata_path, self._handle_metadata)
|
|
209
|
+
|
|
210
|
+
async def _handle_metadata(self, request: web.Request) -> web.Response:
|
|
211
|
+
"""GET handler for the RFC 9728 well-known metadata document."""
|
|
212
|
+
return web.json_response(self._metadata)
|
|
213
|
+
|
|
214
|
+
def _challenge(
|
|
215
|
+
self,
|
|
216
|
+
error: str | None,
|
|
217
|
+
description: str | None,
|
|
218
|
+
scopes: Collection[str] | None = None,
|
|
219
|
+
) -> str:
|
|
220
|
+
# https://datatracker.ietf.org/doc/html/rfc6750#section-3
|
|
221
|
+
# https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
|
|
222
|
+
params = [f'resource_metadata="{self._metadata_url}"']
|
|
223
|
+
if error is not None:
|
|
224
|
+
params.append(f'error="{error}"')
|
|
225
|
+
if description is not None:
|
|
226
|
+
params.append(f'error_description="{description}"')
|
|
227
|
+
if effective := (scopes or self._default_scopes):
|
|
228
|
+
params.append(f'scope="{" ".join(effective)}"')
|
|
229
|
+
return "Bearer " + ", ".join(params)
|
|
230
|
+
|
|
231
|
+
def _fail(
|
|
232
|
+
self,
|
|
233
|
+
status: int,
|
|
234
|
+
error: str | None = None,
|
|
235
|
+
description: str | None = None,
|
|
236
|
+
scopes: Collection[str] | None = None,
|
|
237
|
+
) -> NoReturn:
|
|
238
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#error-handling
|
|
239
|
+
body = error_envelope(MCPError(INVALID_REQUEST, description or "Unauthorized"))
|
|
240
|
+
response = web.json_response(
|
|
241
|
+
body,
|
|
242
|
+
status=status,
|
|
243
|
+
headers={hdrs.WWW_AUTHENTICATE: self._challenge(error, description, scopes)},
|
|
244
|
+
)
|
|
245
|
+
raise _AuthError(response)
|
|
246
|
+
|
|
247
|
+
async def _authenticate(
|
|
248
|
+
self,
|
|
249
|
+
request: web.Request,
|
|
250
|
+
scopes: Collection[str] | None = None,
|
|
251
|
+
) -> TokenInfo:
|
|
252
|
+
"""Validate the request's bearer token and the operation's scopes.
|
|
253
|
+
|
|
254
|
+
Raises ``_AuthError`` with the appropriate challenge when the token is
|
|
255
|
+
missing/invalid (401), malformed (400), or lacks one of ``scopes``
|
|
256
|
+
(403). ``scopes`` is the current operation's scope requirements when
|
|
257
|
+
known.
|
|
258
|
+
"""
|
|
259
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-requirements
|
|
260
|
+
if "access_token" in request.query:
|
|
261
|
+
self._fail(
|
|
262
|
+
400,
|
|
263
|
+
"invalid_request",
|
|
264
|
+
"Access tokens must not be sent in the URI query string",
|
|
265
|
+
scopes=scopes,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# TODO(aiohttp4): Update this for HeadersDictProxy
|
|
269
|
+
headers = request.headers.getall(hdrs.AUTHORIZATION, ())
|
|
270
|
+
if not headers:
|
|
271
|
+
self._fail(401, scopes=scopes)
|
|
272
|
+
if len(headers) > 1:
|
|
273
|
+
self._fail(
|
|
274
|
+
400, "invalid_request", "Multiple Authorization headers", scopes=scopes
|
|
275
|
+
)
|
|
276
|
+
scheme, _, token = headers[0].partition(" ")
|
|
277
|
+
token = token.strip(" ")
|
|
278
|
+
if scheme.lower() != "bearer" or not _B64TOKEN.fullmatch(token):
|
|
279
|
+
self._fail(
|
|
280
|
+
400, "invalid_request", "Malformed Authorization header", scopes=scopes
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
info = await self._validator(token, request)
|
|
284
|
+
if info is None:
|
|
285
|
+
self._fail(
|
|
286
|
+
401, "invalid_token", "Invalid or expired access token", scopes=scopes
|
|
287
|
+
)
|
|
288
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#runtime-insufficient-scope-errors
|
|
289
|
+
if scopes and not all(self._scope_matcher(s, info.scopes) for s in scopes):
|
|
290
|
+
self._fail(403, "insufficient_scope", "Insufficient scope", scopes=scopes)
|
|
291
|
+
return info
|