aiohttp-tiny-mcp 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_tiny_mcp/__init__.py +76 -0
- aiohttp_tiny_mcp/adapter.py +321 -0
- aiohttp_tiny_mcp/auth.py +129 -0
- aiohttp_tiny_mcp/client.py +111 -0
- aiohttp_tiny_mcp/client_base.py +295 -0
- aiohttp_tiny_mcp/console/__init__.py +98 -0
- aiohttp_tiny_mcp/console/console.css +525 -0
- aiohttp_tiny_mcp/console/console.js +1273 -0
- aiohttp_tiny_mcp/console/index.html +100 -0
- aiohttp_tiny_mcp/core.py +327 -0
- aiohttp_tiny_mcp/dispatcher.py +309 -0
- aiohttp_tiny_mcp/endpoint.py +531 -0
- aiohttp_tiny_mcp/exchange.py +279 -0
- aiohttp_tiny_mcp/http_sse.py +267 -0
- aiohttp_tiny_mcp/hub.py +109 -0
- aiohttp_tiny_mcp/models.py +346 -0
- aiohttp_tiny_mcp/namespaces.py +36 -0
- aiohttp_tiny_mcp/postgres.py +454 -0
- aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- aiohttp_tiny_mcp/protocol/selection.py +92 -0
- aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
- aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
- aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
- aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
- aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
- aiohttp_tiny_mcp/py.typed +0 -0
- aiohttp_tiny_mcp/redis.py +195 -0
- aiohttp_tiny_mcp/registry.py +162 -0
- aiohttp_tiny_mcp/request_state.py +107 -0
- aiohttp_tiny_mcp/schema.py +131 -0
- aiohttp_tiny_mcp/sessions.py +347 -0
- aiohttp_tiny_mcp/specs.py +268 -0
- aiohttp_tiny_mcp/sqlite.py +236 -0
- aiohttp_tiny_mcp/sse.py +260 -0
- aiohttp_tiny_mcp/stdio.py +187 -0
- aiohttp_tiny_mcp/stdio_client.py +91 -0
- aiohttp_tiny_mcp/subscriptions.py +124 -0
- aiohttp_tiny_mcp/testing.py +200 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Stateless remote MCP server: aiohttp + pydantic, nothing else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .client_base import ClientError, Elicitor
|
|
7
|
+
from .core import (
|
|
8
|
+
Answer,
|
|
9
|
+
AnswerAction,
|
|
10
|
+
NeedInput,
|
|
11
|
+
elicit,
|
|
12
|
+
elicit_accept,
|
|
13
|
+
elicit_cancel,
|
|
14
|
+
elicit_decline,
|
|
15
|
+
)
|
|
16
|
+
from .endpoint import Endpoint
|
|
17
|
+
from .exchange import Exchange
|
|
18
|
+
from .http_sse import SseEndpoint
|
|
19
|
+
from .hub import Hub, MemoryHub
|
|
20
|
+
from .models import (
|
|
21
|
+
AudioContent,
|
|
22
|
+
CallToolResult,
|
|
23
|
+
Completion,
|
|
24
|
+
GetPromptResult,
|
|
25
|
+
Hint,
|
|
26
|
+
ImageContent,
|
|
27
|
+
PromptMessage,
|
|
28
|
+
ResourceLink,
|
|
29
|
+
TextContent,
|
|
30
|
+
)
|
|
31
|
+
from .namespaces import namespace
|
|
32
|
+
from .protocol.selection import AdapterSet
|
|
33
|
+
from .registry import Registry
|
|
34
|
+
from .request_state import RequestStates
|
|
35
|
+
from .sessions import MemorySessionStore, SessionRecord, SessionStore
|
|
36
|
+
from .sse import SSEResponse
|
|
37
|
+
from .stdio import run_stdio, serve_stdio
|
|
38
|
+
from .stdio_client import StdioClient
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AdapterSet",
|
|
42
|
+
"Answer",
|
|
43
|
+
"AnswerAction",
|
|
44
|
+
"AudioContent",
|
|
45
|
+
"CallToolResult",
|
|
46
|
+
"Client",
|
|
47
|
+
"ClientError",
|
|
48
|
+
"Elicitor",
|
|
49
|
+
"Completion",
|
|
50
|
+
"Endpoint",
|
|
51
|
+
"SSEResponse",
|
|
52
|
+
"Exchange",
|
|
53
|
+
"GetPromptResult",
|
|
54
|
+
"Hint",
|
|
55
|
+
"Hub",
|
|
56
|
+
"ImageContent",
|
|
57
|
+
"MemoryHub",
|
|
58
|
+
"MemorySessionStore",
|
|
59
|
+
"NeedInput",
|
|
60
|
+
"namespace",
|
|
61
|
+
"PromptMessage",
|
|
62
|
+
"Registry",
|
|
63
|
+
"SseEndpoint",
|
|
64
|
+
"RequestStates",
|
|
65
|
+
"ResourceLink",
|
|
66
|
+
"SessionRecord",
|
|
67
|
+
"SessionStore",
|
|
68
|
+
"StdioClient",
|
|
69
|
+
"TextContent",
|
|
70
|
+
"elicit",
|
|
71
|
+
"elicit_accept",
|
|
72
|
+
"elicit_cancel",
|
|
73
|
+
"elicit_decline",
|
|
74
|
+
"run_stdio",
|
|
75
|
+
"serve_stdio",
|
|
76
|
+
]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Protocol adapters and structural interfaces for requests and registries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, ClassVar, Protocol, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
from .core import (
|
|
13
|
+
AnswerAction,
|
|
14
|
+
Call,
|
|
15
|
+
ClientInfo,
|
|
16
|
+
ClientProfile,
|
|
17
|
+
DecodeFailure,
|
|
18
|
+
Failure,
|
|
19
|
+
FailureKind,
|
|
20
|
+
InputRequest,
|
|
21
|
+
NeedsInput,
|
|
22
|
+
Operation,
|
|
23
|
+
Preamble,
|
|
24
|
+
Rejected,
|
|
25
|
+
Value,
|
|
26
|
+
answer_actions,
|
|
27
|
+
decode_failure_target,
|
|
28
|
+
)
|
|
29
|
+
from .hub import Hub
|
|
30
|
+
from .models import (
|
|
31
|
+
CallToolParams,
|
|
32
|
+
CompleteParams,
|
|
33
|
+
ErrorBody,
|
|
34
|
+
ErrorResponse,
|
|
35
|
+
GetPromptParams,
|
|
36
|
+
Implementation,
|
|
37
|
+
Incoming,
|
|
38
|
+
ListParams,
|
|
39
|
+
Params,
|
|
40
|
+
PromptDef,
|
|
41
|
+
ReadResourceParams,
|
|
42
|
+
ResourceDef,
|
|
43
|
+
ResourceTemplateDef,
|
|
44
|
+
ToolDef,
|
|
45
|
+
)
|
|
46
|
+
from .specs import Bound, PromptSpec, ResourceSpec, ToolSpec
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RequestLike(Protocol):
|
|
50
|
+
"""Request interface for AppKey-based dependency injection, including non-HTTP transports."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def app(self) -> Any: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RegistryProtocol(Protocol):
|
|
57
|
+
"""Read-only properties allow registries with concrete dict collections to conform."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def info(self) -> Implementation: ...
|
|
61
|
+
@property
|
|
62
|
+
def instructions(self) -> str | None: ...
|
|
63
|
+
@property
|
|
64
|
+
def hub(self) -> Hub: ...
|
|
65
|
+
@property
|
|
66
|
+
def tools(self) -> Mapping[str, ToolSpec]: ...
|
|
67
|
+
@property
|
|
68
|
+
def resources_fixed(self) -> Mapping[str, ResourceSpec]: ...
|
|
69
|
+
@property
|
|
70
|
+
def resources_templated(self) -> Sequence[ResourceSpec]: ...
|
|
71
|
+
@property
|
|
72
|
+
def prompts(self) -> Mapping[str, PromptSpec]: ...
|
|
73
|
+
@property
|
|
74
|
+
def completer(self) -> Bound | None: ...
|
|
75
|
+
def match_resource(self, uri: str) -> tuple[ResourceSpec, dict[str, str]] | None: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Adapter(ABC):
|
|
79
|
+
"""One instance per revision, stateless and shared across requests."""
|
|
80
|
+
|
|
81
|
+
version: ClassVar[str]
|
|
82
|
+
supersedes: ClassVar[tuple[str, ...]] = ()
|
|
83
|
+
|
|
84
|
+
BASE_FAILURE_MAP: ClassVar[Mapping[FailureKind, tuple[int, int]]] = MappingProxyType(
|
|
85
|
+
{
|
|
86
|
+
FailureKind.PARSE: (-32700, 400),
|
|
87
|
+
FailureKind.MALFORMED: (-32600, 400),
|
|
88
|
+
FailureKind.INVALID_PARAMS: (-32602, 200),
|
|
89
|
+
FailureKind.ORIGIN_REJECTED: (-32600, 403),
|
|
90
|
+
FailureKind.INTERNAL: (-32603, 200),
|
|
91
|
+
FailureKind.INVALID_ARGUMENTS: (-32603, 200),
|
|
92
|
+
FailureKind.UNKNOWN_METHOD: (-32601, 200),
|
|
93
|
+
FailureKind.UNKNOWN_TARGET: (-32601, 200),
|
|
94
|
+
FailureKind.RESOURCE_NOT_FOUND: (-32002, 200),
|
|
95
|
+
FailureKind.HEADER_MISMATCH: (-32603, 200),
|
|
96
|
+
FailureKind.UNSUPPORTED_VERSION: (-32600, 400),
|
|
97
|
+
FailureKind.INPUT_UNSUPPORTED: (-32603, 200),
|
|
98
|
+
FailureKind.MISSING_REQUIRED_CAPABILITY: (-32603, 200),
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
FAILURE_MAP: ClassVar[Mapping[FailureKind, tuple[int, int]]] = BASE_FAILURE_MAP
|
|
102
|
+
|
|
103
|
+
def bind(self, versions: tuple[str, ...]) -> None:
|
|
104
|
+
"""Receive the served revisions once at AdapterSet construction, for discovery."""
|
|
105
|
+
return None # noqa: B027 -- concrete default, most revisions need nothing
|
|
106
|
+
|
|
107
|
+
def check_http(
|
|
108
|
+
self,
|
|
109
|
+
pre: Preamble,
|
|
110
|
+
headers: Mapping[str, str],
|
|
111
|
+
registry: RegistryProtocol | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Raise `Rejected` on a revision-specific HTTP binding violation."""
|
|
114
|
+
return None # noqa: B027 -- concrete default, most revisions check nothing
|
|
115
|
+
|
|
116
|
+
def decode(self, pre: Preamble) -> Sequence[Call | DecodeFailure]:
|
|
117
|
+
"""Decode messages independently; one invalid item does not abort the batch."""
|
|
118
|
+
if pre.parse_error:
|
|
119
|
+
raise Rejected(Failure(FailureKind.PARSE, "invalid JSON body"))
|
|
120
|
+
if pre.is_batch:
|
|
121
|
+
if not self.allows_batch:
|
|
122
|
+
raise Rejected(Failure(FailureKind.MALFORMED, "batching not supported"))
|
|
123
|
+
if not pre.body: # `is_batch` already means the body is a list
|
|
124
|
+
raise Rejected(Failure(FailureKind.MALFORMED, "empty batch"))
|
|
125
|
+
return [self.decode_item(item) for item in pre.body]
|
|
126
|
+
return [self.decode_item(pre.body)]
|
|
127
|
+
|
|
128
|
+
def decode_item(self, item: object) -> Call | DecodeFailure:
|
|
129
|
+
"""Preserve the request id on failure without aborting sibling batch items."""
|
|
130
|
+
if not isinstance(item, dict):
|
|
131
|
+
return DecodeFailure(
|
|
132
|
+
id=None,
|
|
133
|
+
failure=Failure(FailureKind.MALFORMED, "request must be an object"),
|
|
134
|
+
must_respond=True,
|
|
135
|
+
)
|
|
136
|
+
body = cast(dict[str, Any], item)
|
|
137
|
+
try:
|
|
138
|
+
return self.decode_one(body)
|
|
139
|
+
except Rejected as e:
|
|
140
|
+
call_id, must_respond = decode_failure_target(body)
|
|
141
|
+
return DecodeFailure(id=call_id, failure=e.failure, must_respond=must_respond)
|
|
142
|
+
|
|
143
|
+
def decode_one(self, body: dict[str, Any]) -> Call:
|
|
144
|
+
if "id" in body and body["id"] is None:
|
|
145
|
+
raise Rejected(Failure(FailureKind.MALFORMED, "id must not be null"))
|
|
146
|
+
try:
|
|
147
|
+
msg = Incoming.model_validate(body)
|
|
148
|
+
except ValidationError as e:
|
|
149
|
+
raise Rejected(Failure(FailureKind.MALFORMED, str(e))) from None
|
|
150
|
+
operation = self.operation_for(msg.method)
|
|
151
|
+
if operation is None:
|
|
152
|
+
raise Rejected(Failure(FailureKind.UNKNOWN_METHOD, f"unknown method: {msg.method}"))
|
|
153
|
+
self.check_message(operation, msg)
|
|
154
|
+
try:
|
|
155
|
+
params = self.params_model(operation).model_validate(msg.params)
|
|
156
|
+
except ValidationError as e:
|
|
157
|
+
raise Rejected(Failure(FailureKind.INVALID_PARAMS, str(e))) from None
|
|
158
|
+
self.check_params(params)
|
|
159
|
+
return self.build_call(operation, msg, params)
|
|
160
|
+
|
|
161
|
+
def build_call(self, operation: Operation, msg: Incoming, params: Params) -> Call:
|
|
162
|
+
target: str | None = None
|
|
163
|
+
arguments: dict[str, Any] = {}
|
|
164
|
+
match params:
|
|
165
|
+
case CallToolParams() | GetPromptParams():
|
|
166
|
+
target, arguments = params.name, dict(params.arguments)
|
|
167
|
+
case ReadResourceParams():
|
|
168
|
+
target = params.uri
|
|
169
|
+
case CompleteParams():
|
|
170
|
+
arguments = params.model_dump(
|
|
171
|
+
by_alias=True, exclude={"meta", "input_responses", "request_state"}
|
|
172
|
+
)
|
|
173
|
+
case ListParams():
|
|
174
|
+
arguments = {"cursor": params.cursor} if params.cursor else {}
|
|
175
|
+
return Call(
|
|
176
|
+
operation=operation,
|
|
177
|
+
id=msg.id,
|
|
178
|
+
target=target,
|
|
179
|
+
arguments=arguments,
|
|
180
|
+
params=params,
|
|
181
|
+
client=self.client_info_for(params),
|
|
182
|
+
progress_token=params.meta.progress_token,
|
|
183
|
+
log_level=params.meta.log_level,
|
|
184
|
+
answers=self.answers_for(params),
|
|
185
|
+
actions=self.actions_for(params),
|
|
186
|
+
state=params.request_state,
|
|
187
|
+
raw=msg.params,
|
|
188
|
+
is_notification=msg.is_notification,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def params_model(self, operation: Operation) -> type[Params]:
|
|
192
|
+
"""The params model this revision validates `operation` against."""
|
|
193
|
+
return Params
|
|
194
|
+
|
|
195
|
+
def check_message(self, operation: Operation, msg: Incoming) -> None:
|
|
196
|
+
"""Reject an envelope this revision does not allow for `operation`."""
|
|
197
|
+
return None # noqa: B027 -- concrete default, most revisions check nothing
|
|
198
|
+
|
|
199
|
+
def check_params(self, params: Params) -> None:
|
|
200
|
+
"""Reject params this revision requires more of (2026-07-28 `_meta`)."""
|
|
201
|
+
return None # noqa: B027 -- concrete default, most revisions check nothing
|
|
202
|
+
|
|
203
|
+
def client_info_for(self, params: Params) -> ClientInfo:
|
|
204
|
+
"""Client identity, if the revision supplies it."""
|
|
205
|
+
return ClientInfo()
|
|
206
|
+
|
|
207
|
+
def answers_for(self, params: Params) -> Mapping[str, Any]:
|
|
208
|
+
"""MRTR answers, as this revision carries them."""
|
|
209
|
+
return params.input_responses or {}
|
|
210
|
+
|
|
211
|
+
def actions_for(self, params: Params) -> Mapping[str, AnswerAction]:
|
|
212
|
+
"""Whether each answer accepted, declined, or cancelled its request."""
|
|
213
|
+
return answer_actions(params.input_responses)
|
|
214
|
+
|
|
215
|
+
@abstractmethod
|
|
216
|
+
def operation_for(self, method: str) -> Operation | None: ...
|
|
217
|
+
|
|
218
|
+
@abstractmethod
|
|
219
|
+
def method_for(self, operation: Operation) -> str | None: ...
|
|
220
|
+
|
|
221
|
+
def encode(
|
|
222
|
+
self, call: Call, registry: RegistryProtocol, outcome: Value | NeedsInput | Failure
|
|
223
|
+
) -> Mapping[str, Any]:
|
|
224
|
+
"""Encode a final response, identically for JSON, SSE, and stdio."""
|
|
225
|
+
match outcome:
|
|
226
|
+
case Value():
|
|
227
|
+
return self.encode_value(call, registry, outcome.result)
|
|
228
|
+
case NeedsInput():
|
|
229
|
+
return self.encode_input_required(call, registry, outcome)
|
|
230
|
+
case Failure():
|
|
231
|
+
return self.encode_failure(call.id, outcome)
|
|
232
|
+
|
|
233
|
+
@abstractmethod
|
|
234
|
+
def encode_value(
|
|
235
|
+
self, call: Call, registry: RegistryProtocol, result: BaseModel
|
|
236
|
+
) -> Mapping[str, Any]: ...
|
|
237
|
+
|
|
238
|
+
def encode_input_required(
|
|
239
|
+
self, call: Call, registry: RegistryProtocol, out: NeedsInput
|
|
240
|
+
) -> Mapping[str, Any]:
|
|
241
|
+
raise NotImplementedError(f"{self.version} cannot carry MRTR")
|
|
242
|
+
|
|
243
|
+
def encode_failure(self, call_id: Any, failure: Failure) -> Mapping[str, Any]:
|
|
244
|
+
code, _ = self.FAILURE_MAP[failure.kind]
|
|
245
|
+
return ErrorResponse(
|
|
246
|
+
id=call_id, error=ErrorBody(code=code, message=failure.message, data=failure.data)
|
|
247
|
+
).wire()
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def carries_state(self) -> bool:
|
|
251
|
+
"""Client-held state must be sealed on output and verified on return."""
|
|
252
|
+
return self.can_ask or self.asks_in_arguments
|
|
253
|
+
|
|
254
|
+
def http_status(self, failure: Failure) -> int:
|
|
255
|
+
return self.FAILURE_MAP[failure.kind][1]
|
|
256
|
+
|
|
257
|
+
def client_headers(
|
|
258
|
+
self,
|
|
259
|
+
method: str,
|
|
260
|
+
name: str | None = None,
|
|
261
|
+
params: Mapping[str, Any] | None = None,
|
|
262
|
+
tool: ToolDef | None = None,
|
|
263
|
+
) -> Mapping[str, str]:
|
|
264
|
+
"""Required HTTP headers; `name` identifies the tool, resource, or prompt."""
|
|
265
|
+
return {}
|
|
266
|
+
|
|
267
|
+
def client_handshake_params(self, client: ClientProfile) -> Params:
|
|
268
|
+
"""Params for `initialize` or `server/discover`."""
|
|
269
|
+
return Params()
|
|
270
|
+
|
|
271
|
+
def client_decorate_params(self, params: Params, client: ClientProfile) -> Params:
|
|
272
|
+
"""Apply revision-specific metadata before sending a request."""
|
|
273
|
+
return params
|
|
274
|
+
|
|
275
|
+
def client_input_requests(
|
|
276
|
+
self, result: Mapping[str, Any]
|
|
277
|
+
) -> tuple[Mapping[str, InputRequest], Any] | None:
|
|
278
|
+
"""Return input requests and retry state, or None. Stream-pushed requests bypass this."""
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def capabilities(self, registry: RegistryProtocol) -> Mapping[str, Any]:
|
|
282
|
+
"""Capabilities shared by all supported revisions."""
|
|
283
|
+
caps: dict[str, Any] = {}
|
|
284
|
+
if registry.tools:
|
|
285
|
+
caps["tools"] = {"listChanged": True}
|
|
286
|
+
if registry.resources_fixed or registry.resources_templated:
|
|
287
|
+
caps["resources"] = {"listChanged": True, "subscribe": True}
|
|
288
|
+
if registry.prompts:
|
|
289
|
+
caps["prompts"] = {"listChanged": True}
|
|
290
|
+
if registry.completer is not None:
|
|
291
|
+
caps["completions"] = {}
|
|
292
|
+
caps["logging"] = {}
|
|
293
|
+
return caps
|
|
294
|
+
|
|
295
|
+
@abstractmethod
|
|
296
|
+
def describe_server(self, registry: RegistryProtocol, call: Call) -> BaseModel:
|
|
297
|
+
"""Negotiate the requested legacy revision from `call.params.protocol_version`."""
|
|
298
|
+
|
|
299
|
+
@abstractmethod
|
|
300
|
+
def describe_tool(self, spec: ToolSpec) -> ToolDef | None: ...
|
|
301
|
+
|
|
302
|
+
def describe_resource(self, spec: ResourceSpec) -> ResourceDef | ResourceTemplateDef | None:
|
|
303
|
+
"""Resource definitions are shared across revisions."""
|
|
304
|
+
return spec.definition if spec.definition is not None else spec.template
|
|
305
|
+
|
|
306
|
+
def describe_prompt(self, spec: PromptSpec) -> PromptDef | None:
|
|
307
|
+
"""Prompt definitions are shared across revisions."""
|
|
308
|
+
return spec.definition
|
|
309
|
+
|
|
310
|
+
#: MRTR: return questions, then retry with answers.
|
|
311
|
+
can_ask: ClassVar[bool] = False
|
|
312
|
+
#: Push questions on the active stream; receive answers in a separate POST.
|
|
313
|
+
can_push_ask: ClassVar[bool] = False
|
|
314
|
+
#: Pre-elicitation fallback: exchange questions and answers through tool calls.
|
|
315
|
+
asks_in_arguments: ClassVar[bool] = False
|
|
316
|
+
#: Whether a progress notification may carry a human-readable `message`.
|
|
317
|
+
#: It arrived in 2025-03-26.
|
|
318
|
+
progress_message: ClassVar[bool] = True
|
|
319
|
+
allows_batch: ClassVar[bool] = False
|
|
320
|
+
#: Negotiate once via initialize; retain the revision and capabilities in a session.
|
|
321
|
+
has_handshake: ClassVar[bool] = False
|
aiohttp_tiny_mcp/auth.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""HTTP transport for BaseClient. See stdio_client.py for the stdio transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .adapter import Adapter
|
|
12
|
+
from .client_base import BaseClient, Elicitor
|
|
13
|
+
from .models import Implementation
|
|
14
|
+
from .sse import read_sse
|
|
15
|
+
|
|
16
|
+
ANSWER_METHOD = "elicitation/create"
|
|
17
|
+
|
|
18
|
+
NOTIFICATIONS_METHOD = "notifications/message"
|
|
19
|
+
|
|
20
|
+
BASE_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def frames(resp: aiohttp.ClientResponse) -> AsyncIterator[dict[str, Any]]:
|
|
24
|
+
"""Yield JSON or SSE messages immediately; waiting for EOF would deadlock pushed questions."""
|
|
25
|
+
if resp.content_type != "text/event-stream":
|
|
26
|
+
yield await resp.json(content_type=None)
|
|
27
|
+
return
|
|
28
|
+
async for event in read_sse(resp):
|
|
29
|
+
if event.data:
|
|
30
|
+
yield json.loads(event.data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Client(BaseClient):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
adapter: Adapter,
|
|
38
|
+
*,
|
|
39
|
+
client_info: Implementation | None = None,
|
|
40
|
+
session: aiohttp.ClientSession | None = None,
|
|
41
|
+
on_ask: Elicitor | None = None,
|
|
42
|
+
on_notification: Callable[[Mapping[str, Any]], Any] | None = None,
|
|
43
|
+
log_level: str | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(
|
|
46
|
+
adapter,
|
|
47
|
+
client_info=client_info,
|
|
48
|
+
on_ask=on_ask,
|
|
49
|
+
on_notification=on_notification,
|
|
50
|
+
log_level=log_level,
|
|
51
|
+
)
|
|
52
|
+
self.base_url = base_url
|
|
53
|
+
self.session = session
|
|
54
|
+
self.owns_session = session is None
|
|
55
|
+
self.session_id: str | None = None
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> Client:
|
|
58
|
+
if self.owns_session:
|
|
59
|
+
self.session = aiohttp.ClientSession()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
63
|
+
if self.owns_session and self.session is not None:
|
|
64
|
+
await self.session.close()
|
|
65
|
+
|
|
66
|
+
def headers(
|
|
67
|
+
self, method: str, name: str | None = None, params: Any = None, tool: Any = None
|
|
68
|
+
) -> dict[str, str]:
|
|
69
|
+
headers = {
|
|
70
|
+
**BASE_HEADERS,
|
|
71
|
+
**self.adapter.client_headers(method, name, params, tool),
|
|
72
|
+
}
|
|
73
|
+
if self.session_id is not None:
|
|
74
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
75
|
+
return headers
|
|
76
|
+
|
|
77
|
+
async def exchange(
|
|
78
|
+
self, envelope: dict[str, Any], *, method: str, name: str | None
|
|
79
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
80
|
+
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
81
|
+
tool = self.tool_definitions.get(name) if name is not None else None
|
|
82
|
+
headers = self.headers(method, name, envelope.get("params"), tool)
|
|
83
|
+
async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
|
|
84
|
+
issued = resp.headers.get("Mcp-Session-Id")
|
|
85
|
+
if issued:
|
|
86
|
+
self.session_id = issued
|
|
87
|
+
async for frame in frames(resp):
|
|
88
|
+
yield frame
|
|
89
|
+
|
|
90
|
+
async def send_notification(self, envelope: dict[str, Any], *, method: str) -> None:
|
|
91
|
+
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
92
|
+
headers = self.headers(method, params=envelope.get("params"))
|
|
93
|
+
async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
|
|
94
|
+
await resp.read()
|
|
95
|
+
|
|
96
|
+
async def stream_notifications(self) -> AsyncIterator[dict[str, Any]]:
|
|
97
|
+
"""Open the legacy notification stream with GET; keep it open until cancelled."""
|
|
98
|
+
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
99
|
+
headers = self.headers(NOTIFICATIONS_METHOD)
|
|
100
|
+
headers["Accept"] = "text/event-stream"
|
|
101
|
+
async with self.session.get(self.base_url, headers=headers) as resp:
|
|
102
|
+
resp.raise_for_status()
|
|
103
|
+
async for frame in frames(resp):
|
|
104
|
+
yield frame
|
|
105
|
+
|
|
106
|
+
async def reply(self, envelope: dict[str, Any]) -> None:
|
|
107
|
+
"""Send a bare JSON-RPC response. The hub routes it to the node waiting for the answer."""
|
|
108
|
+
assert self.session is not None, "use 'async with Client(...) as client:'"
|
|
109
|
+
headers = self.headers(ANSWER_METHOD)
|
|
110
|
+
async with self.session.post(self.base_url, json=envelope, headers=headers) as resp:
|
|
111
|
+
await resp.read()
|