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,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"]
|