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.
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/PKG-INFO +2 -2
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/README.md +1 -1
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/__init__.py +7 -0
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/auth.py +274 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/endpoint.py +64 -44
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/extensions.py +2 -1
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/http_sse.py +46 -15
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/registry.py +28 -4
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/stdio.py +4 -2
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/tasks.py +37 -0
- aiohttp_tiny_mcp-0.3.6/src/aiohttp_tiny_mcp/auth.py +0 -129
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/adapter.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/client.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/client_base.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/console.css +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/console.js +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/console/index.html +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/dispatcher.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/models.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/postgres.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/redis.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/specs.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
- {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
|
+
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):
|
|
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):
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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) ->
|
|
170
|
-
"""
|
|
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
|
-
|
|
179
|
-
if
|
|
186
|
+
policies = self.registry.auth_policies
|
|
187
|
+
if not policies:
|
|
180
188
|
return None
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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,
|
|
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={
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.6 → aiohttp_tiny_mcp-0.4.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|