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,100 @@
1
+ <!doctype html>
2
+ <html lang="en" data-endpoint="{{ENDPOINT}}" data-console="{{BASE}}">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{TITLE}}</title>
7
+ <link rel="stylesheet" href="{{BASE}}/console.css">
8
+ <link rel="icon" href="data:,">
9
+ </head>
10
+ <body>
11
+
12
+ <header class="bar">
13
+ <h1>{{TITLE}}</h1>
14
+ <span class="field">
15
+ <span>Endpoint</span>
16
+ <code id="endpoint" class="endpoint"></code>
17
+ </span>
18
+ <label class="field">
19
+ <span>Revision</span>
20
+ <select id="revision" class="revision"></select>
21
+ </label>
22
+ <label class="toggle" title="Answer a question the server asks, instead of refusing it">
23
+ <input id="answerable" type="checkbox" checked>
24
+ <span>Can be asked</span>
25
+ </label>
26
+ <button id="connect" class="primary">Connect</button>
27
+ <span id="status" class="status">not connected</span>
28
+ </header>
29
+
30
+ <main class="layout">
31
+
32
+ <nav class="panel catalogue" id="catalogue-panel">
33
+ <div class="panel-head">
34
+ <button id="catalogue-toggle" class="disclose" type="button" aria-expanded="true"
35
+ aria-controls="catalogue" title="Show or hide what this server offers">
36
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
37
+ <path d="M5 3l6 5-6 5" fill="none" stroke="currentColor"
38
+ stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
39
+ </svg>
40
+ </button>
41
+ <h2>Offered</h2>
42
+ <button id="refresh" class="quiet" disabled>Refresh</button>
43
+ </div>
44
+ <div id="catalogue" class="scroll">
45
+ <p class="empty">Connect to see what this server offers.</p>
46
+ </div>
47
+ </nav>
48
+
49
+ <section class="panel work">
50
+ <div class="panel-head">
51
+ <h2 id="subject">Nothing selected</h2>
52
+ <button id="invoke" class="primary" type="submit" form="arguments" disabled>Call</button>
53
+ </div>
54
+ <div class="scroll">
55
+ <p id="greeting" class="greeting">{{DESCRIPTION}}</p>
56
+ <div id="about" class="about"></div>
57
+ <form id="arguments" class="arguments"></form>
58
+ <div id="outcome" class="outcome"></div>
59
+ </div>
60
+ </section>
61
+
62
+ <section class="panel traffic" id="traffic-panel">
63
+ <div class="panel-head">
64
+ <button id="traffic-toggle" class="disclose" type="button" aria-expanded="true"
65
+ aria-controls="traffic" title="Show or hide the traffic">
66
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
67
+ <path d="M5 3l6 5-6 5" fill="none" stroke="currentColor"
68
+ stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
69
+ </svg>
70
+ </button>
71
+ <h2>Traffic</h2>
72
+ <label class="toggle small">
73
+ <input id="follow" type="checkbox" checked>
74
+ <span>Follow</span>
75
+ </label>
76
+ <button id="clear" class="quiet">Clear</button>
77
+ </div>
78
+ <div id="traffic" class="scroll log">
79
+ <p class="empty">Every message, as it goes over the wire.</p>
80
+ </div>
81
+ </section>
82
+
83
+ </main>
84
+
85
+ <dialog id="question" class="question">
86
+ <form method="dialog" id="answer">
87
+ <h2>The server is asking</h2>
88
+ <p id="question-text"></p>
89
+ <div id="question-fields"></div>
90
+ <menu>
91
+ <button value="cancel" class="quiet">Cancel</button>
92
+ <button value="decline" class="quiet">Decline</button>
93
+ <button value="accept" class="primary" autofocus>Accept</button>
94
+ </menu>
95
+ </form>
96
+ </dialog>
97
+
98
+ <script src="{{BASE}}/console.js"></script>
99
+ </body>
100
+ </html>
@@ -0,0 +1,327 @@
1
+ """Protocol-independent calls, outcomes, failures, and elicitation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from .models import Implementation, Params
14
+
15
+
16
+ class Operation(str, Enum):
17
+ DESCRIBE = "describe"
18
+ HANDSHAKE_COMPLETE = "handshake_complete"
19
+ PING = "ping"
20
+ LIST_TOOLS = "list_tools"
21
+ CALL_TOOL = "call_tool"
22
+ LIST_RESOURCES = "list_resources"
23
+ LIST_RESOURCE_TEMPLATES = "list_resource_templates"
24
+ READ_RESOURCE = "read_resource"
25
+ LIST_PROMPTS = "list_prompts"
26
+ GET_PROMPT = "get_prompt"
27
+ COMPLETE = "complete"
28
+ LISTEN = "listen"
29
+ SUBSCRIBE = "subscribe"
30
+ UNSUBSCRIBE = "unsubscribe"
31
+ SET_LOG_LEVEL = "set_log_level"
32
+
33
+
34
+ LOG_LEVELS: tuple[str, ...] = (
35
+ "debug",
36
+ "info",
37
+ "notice",
38
+ "warning",
39
+ "error",
40
+ "critical",
41
+ "alert",
42
+ "emergency",
43
+ )
44
+
45
+
46
+ def logs_at(wanted: str | None, level: str) -> bool:
47
+ """Report the requested severity and above; None disables logging."""
48
+ if wanted is None:
49
+ return False
50
+ try:
51
+ return LOG_LEVELS.index(level) >= LOG_LEVELS.index(wanted)
52
+ except ValueError:
53
+ return True
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ClientProfile:
58
+ """Client identity, capabilities, and desired log level."""
59
+
60
+ info: Implementation
61
+ capabilities: Mapping[str, Any] = field(default_factory=dict)
62
+ log_level: str | None = None
63
+
64
+
65
+ class FailureKind(str, Enum):
66
+ PARSE = "parse"
67
+ MALFORMED = "malformed"
68
+ UNKNOWN_METHOD = "unknown_method"
69
+ UNKNOWN_TARGET = "unknown_target"
70
+ RESOURCE_NOT_FOUND = "resource_not_found"
71
+ INVALID_PARAMS = "invalid_params"
72
+ INVALID_ARGUMENTS = "invalid_arguments"
73
+ HEADER_MISMATCH = "header_mismatch"
74
+ UNSUPPORTED_VERSION = "unsupported_version"
75
+ ORIGIN_REJECTED = "origin_rejected"
76
+ INPUT_UNSUPPORTED = "input_unsupported"
77
+ MISSING_REQUIRED_CAPABILITY = "missing_required_capability"
78
+ INTERNAL = "internal"
79
+
80
+
81
+ InputRequest = Mapping[str, Any]
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class Value:
86
+ result: BaseModel
87
+
88
+
89
+ @dataclass(slots=True)
90
+ class NeedsInput:
91
+ requests: Mapping[str, InputRequest]
92
+ state: Any = None
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class Failure:
97
+ kind: FailureKind
98
+ message: str
99
+ data: Any = None
100
+
101
+
102
+ Outcome = Value | NeedsInput | Failure
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class ClientInfo:
107
+ name: str | None = None
108
+ version: str | None = None
109
+ capabilities: Mapping[str, Any] = field(default_factory=dict)
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class Call:
114
+ operation: Operation
115
+ id: str | int | None
116
+ target: str | None
117
+ arguments: Mapping[str, Any]
118
+ params: Params
119
+ client: ClientInfo
120
+ progress_token: str | int | None = None
121
+ log_level: str | None = None
122
+ answers: Mapping[str, Any] = field(default_factory=dict)
123
+ actions: Mapping[str, AnswerAction] = field(default_factory=dict)
124
+ state: Any = None
125
+ raw: Mapping[str, Any] = field(default_factory=dict)
126
+ is_notification: bool = False
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class DecodeFailure:
131
+ """A failed batch item carried alongside valid calls without aborting the batch."""
132
+
133
+ id: str | int | None
134
+ failure: Failure
135
+ must_respond: bool = True
136
+
137
+
138
+ def decode_failure_target(body: Mapping[str, Any]) -> tuple[str | int | None, bool]:
139
+ """Return the safe response id and whether an invalid message gets a reply.
140
+
141
+ A missing id suppresses a reply only when the object is structurally a
142
+ JSON-RPC notification. Invalid request objects still receive an error with
143
+ a null id. MCP request ids are strings or integers; booleans are not
144
+ integers for this purpose even though ``bool`` subclasses ``int`` in Python.
145
+ """
146
+ if "id" in body:
147
+ value = body["id"]
148
+ usable = isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
149
+ return (value, True) if usable else (None, True)
150
+ params = body.get("params", {})
151
+ is_notification = (
152
+ body.get("jsonrpc") == "2.0"
153
+ and isinstance(body.get("method"), str)
154
+ and isinstance(params, (dict, list))
155
+ )
156
+ return None, not is_notification
157
+
158
+
159
+ VERSION_QUERY_PARAM = "mcp"
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class Preamble:
164
+ raw: bytes
165
+ body: Any
166
+ parse_error: bool
167
+ headers: Mapping[str, str]
168
+ is_batch: bool
169
+ method: str | None
170
+ header_version: str | None
171
+ meta_version: str | None
172
+ query_version: str | None
173
+ initialize_version: str | None
174
+
175
+ @classmethod
176
+ def of(
177
+ cls,
178
+ raw: bytes,
179
+ headers: Mapping[str, str],
180
+ query: Mapping[str, str] | None = None,
181
+ ) -> Preamble:
182
+ try:
183
+ body = json.loads(raw)
184
+ parse_error = False
185
+ except (json.JSONDecodeError, UnicodeDecodeError):
186
+ body = None
187
+ parse_error = True
188
+ method = body.get("method") if isinstance(body, dict) else None
189
+ params = body.get("params") if isinstance(body, dict) else None
190
+ meta = params.get("_meta") if isinstance(params, dict) else None
191
+ meta_version = (
192
+ meta.get("io.modelcontextprotocol/protocolVersion") if isinstance(meta, dict) else None
193
+ )
194
+ initialize_version = (
195
+ params.get("protocolVersion")
196
+ if method == "initialize" and isinstance(params, dict)
197
+ else None
198
+ )
199
+ return cls(
200
+ raw=raw,
201
+ body=body,
202
+ parse_error=parse_error,
203
+ headers=headers,
204
+ is_batch=isinstance(body, list),
205
+ method=method,
206
+ header_version=headers.get("MCP-Protocol-Version"),
207
+ meta_version=meta_version,
208
+ query_version=(query or {}).get(VERSION_QUERY_PARAM),
209
+ initialize_version=initialize_version,
210
+ )
211
+
212
+
213
+ class Rejected(Exception):
214
+ """A protocol failure that aborts request handling before a result is produced."""
215
+
216
+ def __init__(self, failure: Failure) -> None:
217
+ super().__init__(failure.message)
218
+ self.failure = failure
219
+
220
+
221
+ class NeedInput(Exception):
222
+ """Raise from a tool/prompt/resource handler to trigger MRTR."""
223
+
224
+ def __init__(self, requests: Mapping[str, InputRequest], state: Any = None) -> None:
225
+ super().__init__("input required")
226
+ self.requests = requests
227
+ self.state = state
228
+
229
+
230
+ def elicit(message: str, schema: dict[str, Any] | None = None) -> InputRequest:
231
+ """Ask the user something. Omit `schema` to ask only for agreement."""
232
+ return {
233
+ "method": "elicitation/create",
234
+ "params": {
235
+ "message": message,
236
+ "requestedSchema": schema
237
+ if schema is not None
238
+ else {"type": "object", "properties": {}},
239
+ },
240
+ }
241
+
242
+
243
+ def elicit_accept(content: Mapping[str, Any]) -> dict[str, Any]:
244
+ return {"action": "accept", "content": dict(content)}
245
+
246
+
247
+ def elicit_decline() -> dict[str, Any]:
248
+ return {"action": "decline"}
249
+
250
+
251
+ def elicit_cancel() -> dict[str, Any]:
252
+ return {"action": "cancel"}
253
+
254
+
255
+ class AnswerAction(str, Enum):
256
+ """What a client did with one input request (`ElicitResult.action`)."""
257
+
258
+ ACCEPT = "accept"
259
+ DECLINE = "decline"
260
+ CANCEL = "cancel"
261
+
262
+
263
+ @dataclass(frozen=True, slots=True)
264
+ class Answer:
265
+ """Unwrapped reply content and action. Empty content may still mean acceptance."""
266
+
267
+ action: AnswerAction
268
+ content: Mapping[str, Any] = field(default_factory=dict)
269
+
270
+ @property
271
+ def accepted(self) -> bool:
272
+ return self.action is AnswerAction.ACCEPT
273
+
274
+ def get(self, key: str, default: Any = None) -> Any:
275
+ return self.content.get(key, default)
276
+
277
+ def __getitem__(self, key: str) -> Any:
278
+ return self.content[key]
279
+
280
+ def __contains__(self, key: str) -> bool:
281
+ return key in self.content
282
+
283
+
284
+ def answer_of(reply: Mapping[str, Any]) -> Answer:
285
+ """Convert an ElicitResult envelope into the reply a handler sees."""
286
+ action = reply.get("action")
287
+ unwrapped = unwrap_answers({"": reply})
288
+ try:
289
+ resolved = AnswerAction(action) if isinstance(action, str) else AnswerAction.ACCEPT
290
+ except ValueError:
291
+ resolved = AnswerAction.DECLINE
292
+ return Answer(action=resolved, content=unwrapped.get("", {}))
293
+
294
+
295
+ def answer_actions(raw: Mapping[str, Any] | None) -> dict[str, AnswerAction]:
296
+ """Extract acceptance separately from content: empty forms can still be accepted."""
297
+ if not raw:
298
+ return {}
299
+ actions: dict[str, AnswerAction] = {}
300
+ for key, value in raw.items():
301
+ action = value.get("action") if isinstance(value, dict) else None
302
+ if not isinstance(action, str):
303
+ # A bare answer carries content and no verdict: it is an agreement.
304
+ actions[key] = AnswerAction.ACCEPT
305
+ continue
306
+ try:
307
+ actions[key] = AnswerAction(action)
308
+ except ValueError:
309
+ # An action this revision does not define is not agreement.
310
+ actions[key] = AnswerAction.DECLINE
311
+ return actions
312
+
313
+
314
+ def unwrap_answers(raw: Mapping[str, Any] | None) -> dict[str, Any]:
315
+ """Extract answer content from ElicitResult envelopes. Decline/cancel remain present with empty
316
+ content, so they count as answered.
317
+ """
318
+ if not raw:
319
+ return {}
320
+ answers: dict[str, Any] = {}
321
+ for key, value in raw.items():
322
+ if isinstance(value, dict) and "action" in value:
323
+ accepted = value["action"] == "accept"
324
+ answers[key] = (value.get("content") or {}) if accepted else {}
325
+ else:
326
+ answers[key] = value
327
+ return answers