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.
Files changed (41) hide show
  1. aiohttp_tiny_mcp/__init__.py +76 -0
  2. aiohttp_tiny_mcp/adapter.py +321 -0
  3. aiohttp_tiny_mcp/auth.py +129 -0
  4. aiohttp_tiny_mcp/client.py +111 -0
  5. aiohttp_tiny_mcp/client_base.py +295 -0
  6. aiohttp_tiny_mcp/console/__init__.py +98 -0
  7. aiohttp_tiny_mcp/console/console.css +525 -0
  8. aiohttp_tiny_mcp/console/console.js +1273 -0
  9. aiohttp_tiny_mcp/console/index.html +100 -0
  10. aiohttp_tiny_mcp/core.py +327 -0
  11. aiohttp_tiny_mcp/dispatcher.py +309 -0
  12. aiohttp_tiny_mcp/endpoint.py +531 -0
  13. aiohttp_tiny_mcp/exchange.py +279 -0
  14. aiohttp_tiny_mcp/http_sse.py +267 -0
  15. aiohttp_tiny_mcp/hub.py +109 -0
  16. aiohttp_tiny_mcp/models.py +346 -0
  17. aiohttp_tiny_mcp/namespaces.py +36 -0
  18. aiohttp_tiny_mcp/postgres.py +454 -0
  19. aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  20. aiohttp_tiny_mcp/protocol/selection.py +92 -0
  21. aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
  22. aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
  23. aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
  24. aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
  25. aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
  26. aiohttp_tiny_mcp/py.typed +0 -0
  27. aiohttp_tiny_mcp/redis.py +195 -0
  28. aiohttp_tiny_mcp/registry.py +162 -0
  29. aiohttp_tiny_mcp/request_state.py +107 -0
  30. aiohttp_tiny_mcp/schema.py +131 -0
  31. aiohttp_tiny_mcp/sessions.py +347 -0
  32. aiohttp_tiny_mcp/specs.py +268 -0
  33. aiohttp_tiny_mcp/sqlite.py +236 -0
  34. aiohttp_tiny_mcp/sse.py +260 -0
  35. aiohttp_tiny_mcp/stdio.py +187 -0
  36. aiohttp_tiny_mcp/stdio_client.py +91 -0
  37. aiohttp_tiny_mcp/subscriptions.py +124 -0
  38. aiohttp_tiny_mcp/testing.py +200 -0
  39. aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
  40. aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
  41. 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
@@ -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()