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.
@@ -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