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,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))))
|