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,309 @@
1
+ """Maps Operation to registry lookups and produces an Outcome. Knows nothing
2
+ about HTTP, headers or protocol versions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from .auth import Principal
12
+ from .core import (
13
+ LOG_LEVELS,
14
+ Failure,
15
+ FailureKind,
16
+ NeedInput,
17
+ NeedsInput,
18
+ Operation,
19
+ Outcome,
20
+ Value,
21
+ )
22
+ from .exchange import Exchange
23
+ from .models import (
24
+ CallToolResult,
25
+ CompleteResult,
26
+ Completion,
27
+ EmptyResult,
28
+ GetPromptResult,
29
+ ListPromptsResult,
30
+ ListResourcesResult,
31
+ ListResourceTemplatesResult,
32
+ ListToolsResult,
33
+ PromptDef,
34
+ PromptMessage,
35
+ ReadResourceResult,
36
+ ResourceDef,
37
+ ResourceTemplateDef,
38
+ SetLevelParams,
39
+ SubscribeParams,
40
+ TextContent,
41
+ ToolDef,
42
+ )
43
+ from .registry import Registry
44
+ from .request_state import RequestStates, protect_request_state, restore_request_state
45
+ from .specs import ToolSpec
46
+ from .subscriptions import listen, subscribe, unsubscribe
47
+
48
+
49
+ def prompt_result(value: Any) -> GetPromptResult:
50
+ if isinstance(value, GetPromptResult):
51
+ return value
52
+ if isinstance(value, str):
53
+ return GetPromptResult(
54
+ messages=[PromptMessage(role="user", content=TextContent(text=value))]
55
+ )
56
+ return GetPromptResult(messages=list(value))
57
+
58
+
59
+ def refusal(spec: ToolSpec, principal: Principal | None) -> str | None:
60
+ """Why this caller may not call this tool, or `None` where it may.
61
+
62
+ A tool that names scopes is unreachable without a verified caller, on any
63
+ transport. Over stdio there is no token to carry one, and silently
64
+ allowing the call there would make the scope a comment.
65
+ """
66
+ if not spec.scopes:
67
+ return None
68
+ if principal is None:
69
+ return (
70
+ f"{spec.name} requires the scopes {', '.join(sorted(spec.scopes))}, and this "
71
+ f"request carried no verified caller"
72
+ )
73
+ missing = principal.holds(spec.scopes)
74
+ if missing:
75
+ return f"{spec.name} requires the scope {', '.join(sorted(missing))}"
76
+ return None
77
+
78
+
79
+ PUSH_ASK_ROUNDS = 8
80
+
81
+
82
+ class Dispatcher:
83
+ def __init__(self, registry: Registry) -> None:
84
+ self.registry = registry
85
+
86
+ @property
87
+ def request_state(self) -> RequestStates:
88
+ return self.registry.request_state
89
+
90
+ async def run(self, ex: Exchange) -> Outcome:
91
+ resumed: str | None = None
92
+ if ex.adapter.carries_state:
93
+ resumed = ex.call.state if isinstance(ex.call.state, str) else None
94
+ failure = await restore_request_state(ex.call, self.request_state)
95
+ if failure is not None:
96
+ return failure
97
+ operation = ex.call.operation
98
+ try:
99
+ outcome = await self.dispatch(operation, ex)
100
+ except NeedInput as e:
101
+ outcome = await self.resolve_need_input(ex, e)
102
+ except Exception as e: # noqa: BLE001 -- last resort, see the per-operation handlers
103
+ outcome = Failure(FailureKind.INTERNAL, f"{type(e).__name__}: {e}")
104
+
105
+ if isinstance(outcome, NeedsInput):
106
+ outcome = await protect_request_state(ex.call, outcome, self.request_state)
107
+ if resumed is not None:
108
+ await self.request_state.drop(resumed)
109
+ return outcome
110
+
111
+ async def resolve_need_input(self, ex: Exchange, e: NeedInput) -> Outcome:
112
+ if ex.can_ask and ex.adapter.can_push_ask and not ex.adapter.can_ask:
113
+ return await self.ask_on_the_stream(ex, e)
114
+ if ex.can_ask:
115
+ return NeedsInput(requests=e.requests, state=e.state)
116
+ if ex.adapter.can_ask:
117
+ return Failure(
118
+ FailureKind.MISSING_REQUIRED_CAPABILITY,
119
+ "client did not declare the elicitation capability",
120
+ data={"requiredCapabilities": {"elicitation": {}}},
121
+ )
122
+ return Failure(FailureKind.INPUT_UNSUPPORTED, f"{ex.adapter.version} cannot ask the user")
123
+
124
+ async def ask_on_the_stream(self, ex: Exchange, first: NeedInput) -> Outcome:
125
+ """Put each question to the client, then run the handler again.
126
+
127
+ The answers are written where the handler reads them, so one handler
128
+ serves both mechanics: a revision that carries questions in its result
129
+ restarts the call, and this one restarts the handler in place.
130
+ """
131
+ asked = first
132
+ for _ in range(PUSH_ASK_ROUNDS):
133
+ answers = dict(ex.call.answers)
134
+ actions = dict(ex.call.actions)
135
+ for key, request in asked.requests.items():
136
+ answer = await ex.push_ask(key, request)
137
+ answers[key] = dict(answer.content)
138
+ actions[key] = answer.action
139
+ ex.call.answers = answers
140
+ ex.call.actions = actions
141
+ ex.call.state = asked.state
142
+ try:
143
+ return await self.dispatch(ex.call.operation, ex)
144
+ except NeedInput as again:
145
+ asked = again
146
+ return Failure(
147
+ FailureKind.INTERNAL,
148
+ f"{ex.call.target} still asking after {PUSH_ASK_ROUNDS} rounds",
149
+ )
150
+
151
+ async def dispatch(self, operation: Operation, ex: Exchange) -> Outcome:
152
+ """Dispatch explicitly so type checking catches missing or renamed handlers."""
153
+ match operation:
154
+ case Operation.DESCRIBE:
155
+ return await self.describe(ex)
156
+ case Operation.HANDSHAKE_COMPLETE:
157
+ return await self.handshake_complete(ex)
158
+ case Operation.PING:
159
+ return await self.ping(ex)
160
+ case Operation.LIST_TOOLS:
161
+ return await self.list_tools(ex)
162
+ case Operation.CALL_TOOL:
163
+ return await self.call_tool(ex)
164
+ case Operation.LIST_RESOURCES:
165
+ return await self.list_resources(ex)
166
+ case Operation.LIST_RESOURCE_TEMPLATES:
167
+ return await self.list_resource_templates(ex)
168
+ case Operation.READ_RESOURCE:
169
+ return await self.read_resource(ex)
170
+ case Operation.LIST_PROMPTS:
171
+ return await self.list_prompts(ex)
172
+ case Operation.GET_PROMPT:
173
+ return await self.get_prompt(ex)
174
+ case Operation.COMPLETE:
175
+ return await self.complete(ex)
176
+ case Operation.LISTEN:
177
+ return Value(result=await listen(ex))
178
+ case Operation.SET_LOG_LEVEL:
179
+ return await self.set_log_level(ex)
180
+ case Operation.SUBSCRIBE:
181
+ return await self.subscribe(ex, wanted=True)
182
+ case Operation.UNSUBSCRIBE:
183
+ return await self.subscribe(ex, wanted=False)
184
+
185
+ async def set_log_level(self, ex: Exchange) -> Outcome:
186
+ """Persist the log level in the session for subsequent requests on any node."""
187
+ assert isinstance(ex.call.params, SetLevelParams)
188
+ level = ex.call.params.level
189
+ if level not in LOG_LEVELS:
190
+ return Failure(FailureKind.INVALID_PARAMS, f"unknown severity {level!r}")
191
+ if ex.session is not None:
192
+ await ex.session.set_log_level(level)
193
+ elif ex.keep_log_level is not None:
194
+ ex.keep_log_level(level)
195
+ else:
196
+ return Failure(
197
+ FailureKind.INVALID_PARAMS,
198
+ "logging/setLevel needs the session the handshake issued",
199
+ )
200
+ return Value(result=EmptyResult())
201
+
202
+ async def subscribe(self, ex: Exchange, *, wanted: bool) -> Outcome:
203
+ """Persist resource subscriptions for the node serving the notification stream."""
204
+ assert isinstance(ex.call.params, SubscribeParams)
205
+ uri = ex.call.params.uri
206
+ if ex.session is None:
207
+ return Failure(
208
+ FailureKind.INVALID_PARAMS,
209
+ "resources/subscribe needs the session the handshake issued",
210
+ )
211
+ if wanted and self.registry.match_resource(uri) is None:
212
+ return Failure(FailureKind.RESOURCE_NOT_FOUND, f"no resource matches {uri}")
213
+ await (subscribe if wanted else unsubscribe)(ex.session, uri)
214
+ return Value(result=EmptyResult())
215
+
216
+ async def describe(self, ex: Exchange) -> Outcome:
217
+ return Value(result=ex.adapter.describe_server(self.registry, ex.call))
218
+
219
+ async def handshake_complete(self, ex: Exchange) -> Outcome:
220
+ return Value(result=EmptyResult())
221
+
222
+ async def ping(self, ex: Exchange) -> Outcome:
223
+ return Value(result=EmptyResult())
224
+
225
+ async def list_tools(self, ex: Exchange) -> Outcome:
226
+ tools: list[ToolDef] = [
227
+ d
228
+ for spec in self.registry.tools.values()
229
+ if (d := ex.adapter.describe_tool(spec)) is not None
230
+ ]
231
+ tools.sort(key=lambda item: item.name)
232
+ return Value(result=ListToolsResult(tools=tools))
233
+
234
+ async def call_tool(self, ex: Exchange) -> Outcome:
235
+ spec = self.registry.tools.get(ex.call.target or "")
236
+ if spec is None or ex.adapter.describe_tool(spec) is None:
237
+ return Failure(FailureKind.UNKNOWN_TARGET, f"unknown tool: {ex.call.target}")
238
+ if (refused := refusal(spec, ex.principal)) is not None:
239
+ return Value(result=CallToolResult.failure(refused))
240
+ try:
241
+ value = await spec.bound.call(dict(ex.call.arguments), ex)
242
+ except ValidationError as e:
243
+ return Value(result=CallToolResult.failure(f"invalid arguments:\n{e}"))
244
+ except NeedInput:
245
+ raise
246
+ except Exception as e: # noqa: BLE001 -- tool faults are results, not RPC errors
247
+ return Value(result=CallToolResult.failure(f"{type(e).__name__}: {e}"))
248
+ return Value(result=CallToolResult.of(value))
249
+
250
+ async def list_resources(self, ex: Exchange) -> Outcome:
251
+ defs: list[ResourceDef] = [
252
+ d
253
+ for spec in self.registry.resources_fixed.values()
254
+ if isinstance(d := ex.adapter.describe_resource(spec), ResourceDef)
255
+ ]
256
+ defs.sort(key=lambda item: item.uri)
257
+ return Value(result=ListResourcesResult(resources=defs))
258
+
259
+ async def list_resource_templates(self, ex: Exchange) -> Outcome:
260
+ defs: list[ResourceTemplateDef] = [
261
+ d
262
+ for spec in self.registry.resources_templated
263
+ if isinstance(d := ex.adapter.describe_resource(spec), ResourceTemplateDef)
264
+ ]
265
+ defs.sort(key=lambda item: item.uri_template)
266
+ return Value(result=ListResourceTemplatesResult(resource_templates=defs))
267
+
268
+ async def read_resource(self, ex: Exchange) -> Outcome:
269
+ uri = ex.call.target or ""
270
+ found = self.registry.match_resource(uri)
271
+ if found is None:
272
+ return Failure(FailureKind.RESOURCE_NOT_FOUND, f"resource not found: {uri}")
273
+ spec, variables = found
274
+ if ex.adapter.describe_resource(spec) is None:
275
+ return Failure(FailureKind.RESOURCE_NOT_FOUND, f"resource not found: {uri}")
276
+ try:
277
+ value = await spec.bound.call(variables, ex)
278
+ except ValidationError as e:
279
+ return Failure(FailureKind.INVALID_PARAMS, str(e))
280
+ return Value(result=ReadResourceResult(contents=spec.contents(uri, value)))
281
+
282
+ async def list_prompts(self, ex: Exchange) -> Outcome:
283
+ prompts: list[PromptDef] = [
284
+ d
285
+ for spec in self.registry.prompts.values()
286
+ if (d := ex.adapter.describe_prompt(spec)) is not None
287
+ ]
288
+ prompts.sort(key=lambda item: item.name)
289
+ return Value(result=ListPromptsResult(prompts=prompts))
290
+
291
+ async def get_prompt(self, ex: Exchange) -> Outcome:
292
+ spec = self.registry.prompts.get(ex.call.target or "")
293
+ if spec is None or ex.adapter.describe_prompt(spec) is None:
294
+ return Failure(FailureKind.UNKNOWN_TARGET, f"unknown prompt: {ex.call.target}")
295
+ try:
296
+ value = await spec.bound.call(dict(ex.call.arguments), ex)
297
+ except ValidationError as e:
298
+ return Failure(FailureKind.INVALID_PARAMS, str(e))
299
+ return Value(result=prompt_result(value))
300
+
301
+ async def complete(self, ex: Exchange) -> Outcome:
302
+ completer = self.registry.completer
303
+ if completer is None:
304
+ return Value(result=CompleteResult(completion=Completion(values=[])))
305
+ value = await completer.call(dict(ex.call.arguments), ex)
306
+ if isinstance(value, Completion):
307
+ return Value(result=CompleteResult(completion=value))
308
+ values = list(value)[:100]
309
+ return Value(result=CompleteResult(completion=Completion(values=values, total=len(values))))