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,295 @@
1
+ """Shared client request building, result parsing, and elicitation handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
8
+ from typing import Any
9
+
10
+ from .adapter import Adapter
11
+ from .core import ClientProfile, InputRequest, Operation
12
+ from .models import (
13
+ CallToolParams,
14
+ CallToolResult,
15
+ CompleteArgument,
16
+ CompleteParams,
17
+ CompleteRef,
18
+ CompleteResult,
19
+ GetPromptParams,
20
+ GetPromptResult,
21
+ Implementation,
22
+ ListenNotifications,
23
+ ListenParams,
24
+ ListParams,
25
+ ListPromptsResult,
26
+ ListResourcesResult,
27
+ ListToolsResult,
28
+ Params,
29
+ PromptDef,
30
+ ReadResourceParams,
31
+ ReadResourceResult,
32
+ ResourceDef,
33
+ SetLevelParams,
34
+ SubscribeParams,
35
+ ToolDef,
36
+ )
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ Elicitor = Callable[[InputRequest], Awaitable[Mapping[str, Any]]]
41
+
42
+ ASK_ROUNDS = 8
43
+
44
+ ACKNOWLEDGED = "notifications/subscriptions/acknowledged"
45
+
46
+
47
+ class ClientError(Exception):
48
+ def __init__(self, code: int, message: str, data: Any = None) -> None:
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.data = data
52
+
53
+
54
+ class BaseClient(ABC):
55
+ def __init__(
56
+ self,
57
+ adapter: Adapter,
58
+ *,
59
+ client_info: Implementation | None = None,
60
+ on_ask: Elicitor | None = None,
61
+ on_notification: Callable[[Mapping[str, Any]], Any] | None = None,
62
+ log_level: str | None = None,
63
+ ) -> None:
64
+ self.adapter = adapter
65
+ self.client_info = client_info or Implementation(
66
+ name="aiohttp-tiny-mcp-client", version="0.1.0"
67
+ )
68
+ self.log_level = log_level
69
+ self.on_ask = on_ask
70
+ self.on_notification = on_notification
71
+ self.next_id_counter = 0
72
+ self.tool_definitions: dict[str, ToolDef] = {}
73
+ self.accepted: Mapping[str, Any] = {}
74
+
75
+ @property
76
+ def capabilities(self) -> Mapping[str, Any]:
77
+ """MRTR can be answered manually; pushed questions require an on_ask callback."""
78
+ if self.on_ask is not None or self.adapter.can_ask:
79
+ return {"elicitation": {}}
80
+ return {}
81
+
82
+ @property
83
+ def profile(self) -> ClientProfile:
84
+ """Client identity, capabilities, and desired log level for the adapter."""
85
+ return ClientProfile(
86
+ info=self.client_info, capabilities=self.capabilities, log_level=self.log_level
87
+ )
88
+
89
+ def next_id(self) -> int:
90
+ self.next_id_counter += 1
91
+ return self.next_id_counter
92
+
93
+ @abstractmethod
94
+ def exchange(
95
+ self, envelope: dict[str, Any], *, method: str, name: str | None
96
+ ) -> AsyncIterator[dict[str, Any]]:
97
+ """Yield messages as they arrive; waiting for EOF would deadlock pushed questions."""
98
+
99
+ @abstractmethod
100
+ async def send_notification(self, envelope: dict[str, Any], *, method: str) -> None:
101
+ """Send a notification envelope (no `id`); no reply is expected."""
102
+
103
+ @abstractmethod
104
+ async def reply(self, envelope: dict[str, Any]) -> None:
105
+ """Answer a request the server made. Carries no reply of its own."""
106
+
107
+ def stream_notifications(self) -> AsyncIterator[dict[str, Any]]:
108
+ """Open a separate legacy notification channel, if the transport supports one."""
109
+ raise NotImplementedError(
110
+ f"{type(self).__name__} has no separate stream to read notifications on"
111
+ )
112
+
113
+ def answers(self, frame: Mapping[str, Any], request_id: Any) -> bool:
114
+ """Match the request id, accepting null for errors raised before the server read it."""
115
+ if "result" not in frame and "error" not in frame:
116
+ return False
117
+ return frame.get("id") == request_id or ("error" in frame and frame.get("id") is None)
118
+
119
+ async def incoming(self, frame: Mapping[str, Any]) -> None:
120
+ """Handle one message that is not the reply being waited for."""
121
+ if frame.get("id") is None:
122
+ if self.on_notification is not None:
123
+ await_me = self.on_notification(frame)
124
+ if isinstance(await_me, Awaitable):
125
+ await await_me
126
+ return
127
+ if self.on_ask is None or "method" not in frame:
128
+ await self.reply(
129
+ {
130
+ "jsonrpc": "2.0",
131
+ "id": frame["id"],
132
+ "error": {"code": -32601, "message": f"unsupported: {frame.get('method')}"},
133
+ }
134
+ )
135
+ return
136
+ answer = await self.on_ask({"method": frame["method"], "params": frame.get("params") or {}})
137
+ await self.reply({"jsonrpc": "2.0", "id": frame["id"], "result": dict(answer)})
138
+
139
+ async def request(
140
+ self, operation: Operation, params: Params, *, name: str | None = None
141
+ ) -> dict[str, Any]:
142
+ method = self.adapter.method_for(operation)
143
+ if method is None:
144
+ raise ValueError(f"{self.adapter.version} does not expose {operation}")
145
+ params = self.adapter.client_decorate_params(params, self.profile)
146
+ envelope = {
147
+ "jsonrpc": "2.0",
148
+ "id": self.next_id(),
149
+ "method": method,
150
+ "params": params.wire(),
151
+ }
152
+ body = None
153
+ async for frame in self.exchange(envelope, method=method, name=name):
154
+ if self.answers(frame, envelope["id"]):
155
+ body = frame
156
+ break
157
+ await self.incoming(frame)
158
+ if body is None:
159
+ raise ClientError(-32000, f"{method} ended without a reply")
160
+ if "error" in body:
161
+ error = body["error"]
162
+ raise ClientError(error["code"], error["message"], error.get("data"))
163
+ return body["result"]
164
+
165
+ async def notify(self, method: str, params: Params) -> None:
166
+ params = self.adapter.client_decorate_params(params, self.profile)
167
+ envelope = {"jsonrpc": "2.0", "method": method, "params": params.wire()}
168
+ await self.send_notification(envelope, method=method)
169
+
170
+ async def initialize(self) -> dict[str, Any]:
171
+ params = self.adapter.client_handshake_params(self.profile)
172
+ result = await self.request(Operation.DESCRIBE, params)
173
+ complete_method = self.adapter.method_for(Operation.HANDSHAKE_COMPLETE)
174
+ if complete_method is not None:
175
+ await self.notify(complete_method, Params())
176
+ if self.log_level is not None:
177
+ await self.set_log_level(self.log_level)
178
+ return result
179
+
180
+ async def set_log_level(self, level: str) -> None:
181
+ """Request this severity and above, via session state or per-request metadata."""
182
+ if self.adapter.method_for(Operation.SET_LOG_LEVEL) is not None:
183
+ await self.request(Operation.SET_LOG_LEVEL, SetLevelParams(level=level))
184
+ self.log_level = level
185
+
186
+ async def list_tools(self) -> list[ToolDef]:
187
+ result = await self.request(Operation.LIST_TOOLS, ListParams())
188
+ tools = ListToolsResult.model_validate(result).tools
189
+ self.tool_definitions = {tool.name: tool for tool in tools}
190
+ return tools
191
+
192
+ async def call_tool(
193
+ self,
194
+ name: str,
195
+ arguments: dict[str, Any],
196
+ *,
197
+ input_responses: dict[str, Any] | None = None,
198
+ request_state: Any = None,
199
+ tool_definition: ToolDef | None = None,
200
+ ) -> CallToolResult | dict[str, Any]:
201
+ if tool_definition is not None:
202
+ self.tool_definitions[name] = tool_definition
203
+ answers = dict(input_responses or {})
204
+ state = request_state
205
+ for _ in range(ASK_ROUNDS):
206
+ params = CallToolParams(
207
+ name=name,
208
+ arguments=arguments,
209
+ input_responses=answers or None,
210
+ request_state=state,
211
+ )
212
+ result = await self.request(Operation.CALL_TOOL, params, name=name)
213
+ asked = self.adapter.client_input_requests(result)
214
+ if asked is None:
215
+ return CallToolResult.model_validate(result)
216
+ if self.on_ask is None:
217
+ # Without a callback, return questions for the caller to answer manually.
218
+ return result
219
+ requests, state = asked
220
+ # Resend earlier answers because the handler restarts on each round.
221
+ for key, request in requests.items():
222
+ answers[key] = dict(await self.on_ask(request))
223
+ raise ClientError(-32000, f"{name} still asking after {ASK_ROUNDS} rounds")
224
+
225
+ async def listen(
226
+ self,
227
+ *,
228
+ resources: Sequence[str] = (),
229
+ tools_changed: bool = False,
230
+ prompts_changed: bool = False,
231
+ resources_changed: bool = False,
232
+ ) -> AsyncIterator[dict[str, Any]]:
233
+ """Yield changes until cancelled, via a request stream or legacy resource subscriptions."""
234
+ wanted = ListenNotifications(
235
+ tools_list_changed=tools_changed,
236
+ prompts_list_changed=prompts_changed,
237
+ resources_list_changed=resources_changed,
238
+ resource_subscriptions=list(resources),
239
+ )
240
+ method = self.adapter.method_for(Operation.LISTEN)
241
+ if method is not None:
242
+ async for frame in self.listen_in_one_request(method, wanted):
243
+ yield frame
244
+ return
245
+ for uri in resources:
246
+ await self.request(Operation.SUBSCRIBE, SubscribeParams(uri=uri), name=uri)
247
+ # Legacy list changes are enabled by the handshake capabilities.
248
+ async for frame in self.stream_notifications():
249
+ if "method" in frame and frame.get("id") is None:
250
+ yield frame
251
+
252
+ async def listen_in_one_request(
253
+ self, method: str, wanted: ListenNotifications
254
+ ) -> AsyncIterator[dict[str, Any]]:
255
+ params = self.adapter.client_decorate_params(
256
+ ListenParams(notifications=wanted), self.profile
257
+ )
258
+ envelope = {
259
+ "jsonrpc": "2.0",
260
+ "id": self.next_id(),
261
+ "method": method,
262
+ "params": params.wire(),
263
+ }
264
+ async for frame in self.exchange(envelope, method=method, name=None):
265
+ if frame.get("id") == envelope["id"]:
266
+ return # the request answered, so the subscription is over
267
+ if frame.get("method") == ACKNOWLEDGED:
268
+ # Keep acknowledgment details separate from change notifications.
269
+ self.accepted = (frame.get("params") or {}).get("notifications") or {}
270
+ continue
271
+ if "method" in frame:
272
+ yield frame
273
+
274
+ async def list_resources(self) -> list[ResourceDef]:
275
+ result = await self.request(Operation.LIST_RESOURCES, ListParams())
276
+ return ListResourcesResult.model_validate(result).resources
277
+
278
+ async def read_resource(self, uri: str) -> ReadResourceResult:
279
+ result = await self.request(Operation.READ_RESOURCE, ReadResourceParams(uri=uri), name=uri)
280
+ return ReadResourceResult.model_validate(result)
281
+
282
+ async def list_prompts(self) -> list[PromptDef]:
283
+ result = await self.request(Operation.LIST_PROMPTS, ListParams())
284
+ return ListPromptsResult.model_validate(result).prompts
285
+
286
+ async def get_prompt(self, name: str, arguments: dict[str, str]) -> GetPromptResult:
287
+ result = await self.request(
288
+ Operation.GET_PROMPT, GetPromptParams(name=name, arguments=arguments), name=name
289
+ )
290
+ return GetPromptResult.model_validate(result)
291
+
292
+ async def complete(self, ref: dict[str, Any], argument: dict[str, str]) -> CompleteResult:
293
+ params = CompleteParams(ref=CompleteRef(**ref), argument=CompleteArgument(**argument))
294
+ result = await self.request(Operation.COMPLETE, params)
295
+ return CompleteResult.model_validate(result)
@@ -0,0 +1,98 @@
1
+ """Optional HTML console for an MCP endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from html import escape
8
+ from pathlib import Path
9
+
10
+ from aiohttp import web
11
+
12
+ log = logging.getLogger("aiohttp_tiny_mcp")
13
+
14
+ HERE = Path(__file__).parent
15
+
16
+ FILES = {
17
+ "": ("index.html", "text/html"),
18
+ "console.js": ("console.js", "application/javascript"),
19
+ "console.css": ("console.css", "text/css"),
20
+ }
21
+
22
+
23
+ class Console:
24
+ """Serve the console."""
25
+
26
+ def __init__(
27
+ self,
28
+ endpoint_path: str = "/mcp",
29
+ *,
30
+ title: str = "MCP console",
31
+ description: str = "",
32
+ template: str | Path | None = None,
33
+ variables: Mapping[str, str] | None = None,
34
+ ) -> None:
35
+ self.endpoint_path = endpoint_path
36
+ self.title = title
37
+ self.description = description
38
+ self.template = Path(template) if template else HERE / "index.html"
39
+ self.variables = dict(variables or {})
40
+
41
+ def substitutions(self, base: str) -> dict[str, str]:
42
+ return {
43
+ "ENDPOINT": self.endpoint_path,
44
+ "BASE": base,
45
+ "TITLE": self.title,
46
+ "DESCRIPTION": self.description,
47
+ **self.variables,
48
+ }
49
+
50
+ def render(self, name: str, base: str = "/console") -> str:
51
+ source = self.template if name == "index.html" else HERE / name
52
+ text = source.read_text(encoding="utf-8")
53
+ for key, value in self.substitutions(base).items():
54
+ text = text.replace("{{" + key + "}}", escape(str(value), quote=True))
55
+ return text
56
+
57
+ def reached(self, request: web.Request, wanted: str) -> str:
58
+ """The prefix the browser used, taken from this request.
59
+
60
+ The page prints absolute addresses, and only the request knows them. A
61
+ subapplication adds its prefix to the request path and not to the route,
62
+ so a stored prefix would be wrong there.
63
+ """
64
+ path = request.path
65
+ if wanted:
66
+ path = path[: -(len(wanted) + 1)]
67
+ return path.rstrip("/")
68
+
69
+ async def handle(self, request: web.Request) -> web.StreamResponse:
70
+ wanted = request.match_info.get("file", "")
71
+ found = FILES.get(wanted)
72
+ if found is None:
73
+ raise web.HTTPNotFound
74
+ name, content_type = found
75
+ return web.Response(
76
+ text=self.render(name, self.reached(request, wanted)),
77
+ content_type=content_type,
78
+ charset="utf-8",
79
+ headers={"Cache-Control": "no-store"},
80
+ )
81
+
82
+ def routes(self, path: str = "/console") -> list[web.RouteDef]:
83
+ """The page, either way it is addressed, and its two files."""
84
+ base = path.rstrip("/")
85
+ log.debug("console at %s, for the endpoint at %s", base, self.endpoint_path)
86
+ return [
87
+ web.get(base, self.handle),
88
+ web.get(f"{base}/", self.handle),
89
+ web.get(f"{base}/{{file}}", self.handle),
90
+ ]
91
+
92
+ def setup(self, app: web.Application, path: str = "/console") -> web.Application:
93
+ log.debug("adding the console routes to %r", app)
94
+ app.add_routes(self.routes(path))
95
+ return app
96
+
97
+
98
+ __all__ = ["Console"]