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,162 @@
1
+ """Public API: declare tools, resources and prompts once, revision-free."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any
7
+
8
+ from .auth import Authorization, Principal
9
+ from .exchange import Exchange, Instance
10
+ from .hub import Hub, MemoryHub
11
+ from .models import Implementation
12
+ from .request_state import DEFAULT_TTL_SECONDS as STATE_TTL_SECONDS
13
+ from .request_state import RequestStates
14
+ from .sessions import DEFAULT_TTL_SECONDS, MemorySessionStore, SessionStore
15
+ from .specs import Bound, PromptSpec, ResourceSpec, ToolSpec
16
+
17
+
18
+ class Registry:
19
+ """Register handlers as decorators or by passing functions directly.
20
+
21
+ @registry.tool
22
+ async def add(args: Add) -> int: ...
23
+
24
+ registry.tool(add, name="sum")
25
+ registry.resource("config://app", config)
26
+
27
+ Uses process-local memory backends by default. Supply shared `hub` and
28
+ `session_store` implementations before running more than one worker.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ name: str,
34
+ version: str,
35
+ *,
36
+ hub: Hub | None = None,
37
+ session_store: SessionStore | None = None,
38
+ auth: Authorization | None = None,
39
+ instructions: str | None = None,
40
+ session_ttl_seconds: int = DEFAULT_TTL_SECONDS,
41
+ request_state_ttl_seconds: int = STATE_TTL_SECONDS,
42
+ hub_poll_seconds: float = 30.0,
43
+ ask_timeout_seconds: float = 120.0,
44
+ ) -> None:
45
+ self.info = Implementation(name=name, version=version)
46
+ self.instructions = instructions
47
+ self.hub = hub if hub is not None else MemoryHub()
48
+ self.session_store = session_store if session_store is not None else MemorySessionStore()
49
+ self.auth = auth
50
+ self.session_ttl_seconds = session_ttl_seconds
51
+ self.request_state = RequestStates(self.session_store, request_state_ttl_seconds)
52
+ self.hub_poll_seconds = hub_poll_seconds
53
+ self.ask_timeout_seconds = ask_timeout_seconds
54
+ self.tools: dict[str, ToolSpec] = {}
55
+ self.resources_fixed: dict[str, ResourceSpec] = {}
56
+ self.resources_templated: list[ResourceSpec] = []
57
+ self.prompts: dict[str, PromptSpec] = {}
58
+ self.completer: Bound | None = None
59
+ self.providers: dict[type, Any] = {}
60
+
61
+ def provide(self, kind: type, source: Any) -> None:
62
+ """Bind a type to where it comes from. Call before registering
63
+ handlers -- plans are checked at registration, not at first call.
64
+
65
+ `source` is an async callable taking the exchange, an async generator
66
+ for anything that must be released afterwards, or a `web.AppKey`. For
67
+ an object that already exists, use `provide_instance` instead.
68
+ """
69
+ self.providers[kind] = source
70
+
71
+ def provide_instance(self, value: Any, kind: type | None = None) -> None:
72
+ """Register an existing object under its type, or under an explicit `kind` such as a base
73
+ class or protocol. Use `provide` for per-request construction or cleanup.
74
+ """
75
+ if kind is None and isinstance(value, type):
76
+ raise TypeError(
77
+ f"provide_instance was given the class {value.__name__} rather than an "
78
+ f"instance of it. Pass the object, or say kind=type to provide the class "
79
+ f"itself."
80
+ )
81
+ self.providers[kind or type(value)] = Instance(value)
82
+
83
+ def check(self, what: str, bound: Bound) -> None:
84
+ for name, kind in bound.plan:
85
+ if kind is Principal:
86
+ if self.auth is None:
87
+ raise TypeError(
88
+ f"{what}: parameter {name!r} wants a Principal, but this registry "
89
+ f"has no auth= -- pass an Authorization to verify tokens first"
90
+ )
91
+ continue
92
+ if kind not in self.providers and kind is not Exchange:
93
+ label = getattr(kind, "__name__", kind)
94
+ raise TypeError(
95
+ f"{what}: parameter {name!r} wants {label}, which has no provider -- "
96
+ f"call registry.provide_instance() or registry.provide() first"
97
+ )
98
+
99
+ def tool(self, fn: Callable[..., Awaitable[Any]] | None = None, **kw: Any):
100
+ def register(fn: Callable[..., Awaitable[Any]]):
101
+ spec = ToolSpec.build(fn, **kw)
102
+ if spec.scopes and self.auth is None:
103
+ raise TypeError(
104
+ f"{spec.name}: scopes were declared, but this registry has no auth= -- "
105
+ f"a scope nothing verifies is a check that never runs"
106
+ )
107
+ self.check(spec.name, spec.bound)
108
+ if spec.name in self.tools:
109
+ raise ValueError(f"duplicate tool: {spec.name}")
110
+ self.tools[spec.name] = spec
111
+ return fn
112
+
113
+ return register(fn) if fn else register
114
+
115
+ def resource(self, uri: str, fn: Callable[..., Awaitable[Any]] | None = None, **kw: Any):
116
+ def register(fn: Callable[..., Awaitable[Any]]):
117
+ spec = ResourceSpec.build(uri, fn, **kw)
118
+ self.check(uri, spec.bound)
119
+ if spec.definition is not None:
120
+ if spec.definition.uri in self.resources_fixed:
121
+ raise ValueError(f"duplicate resource: {spec.definition.uri}")
122
+ self.resources_fixed[spec.definition.uri] = spec
123
+ else:
124
+ template = spec.template.uri_template if spec.template is not None else uri
125
+ if any(
126
+ item.template is not None and item.template.uri_template == template
127
+ for item in self.resources_templated
128
+ ):
129
+ raise ValueError(f"duplicate resource template: {template}")
130
+ self.resources_templated.append(spec)
131
+ return fn
132
+
133
+ return register(fn) if fn else register
134
+
135
+ def prompt(self, fn: Callable[..., Awaitable[Any]] | None = None, **kw: Any):
136
+ def register(fn: Callable[..., Awaitable[Any]]):
137
+ spec = PromptSpec.build(fn, **kw)
138
+ self.check(spec.name, spec.bound)
139
+ if spec.name in self.prompts:
140
+ raise ValueError(f"duplicate prompt: {spec.name}")
141
+ self.prompts[spec.name] = spec
142
+ return fn
143
+
144
+ return register(fn) if fn else register
145
+
146
+ def completions(self, fn: Callable[..., Awaitable[Any]]):
147
+ """(CompleteParams-shaped model, deps) -> list[str] | Completion."""
148
+ if self.completer is not None:
149
+ raise ValueError("duplicate completions handler")
150
+ self.completer = Bound.of(fn)
151
+ self.check("completions", self.completer)
152
+ return fn
153
+
154
+ def match_resource(self, uri: str) -> tuple[ResourceSpec, dict[str, str]] | None:
155
+ fixed = self.resources_fixed.get(uri)
156
+ if fixed is not None:
157
+ return fixed, {}
158
+ for spec in self.resources_templated:
159
+ m = spec.pattern.match(uri)
160
+ if m:
161
+ return spec, m.groupdict()
162
+ return None
@@ -0,0 +1,107 @@
1
+ """Store handler state between input rounds; clients carry only an opaque id.
2
+
3
+ Ids are bound to the original call, scoped by namespace, and expire independently of sessions.
4
+ Shared storage allows retries on another node.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import secrets
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from .core import Call, Failure, FailureKind, NeedsInput
16
+ from .namespaces import scoped
17
+ from .sessions import SessionStore
18
+
19
+ PREFIX = "state/"
20
+
21
+ DEFAULT_TTL_SECONDS = 600
22
+
23
+ BINDING_KEY = "binding"
24
+ PAYLOAD_KEY = "payload"
25
+
26
+
27
+ def binding_of(call: Call) -> str:
28
+ """Hash normalized call arguments and target so state cannot be reused for a different call."""
29
+ material = json.dumps(
30
+ {
31
+ "operation": call.operation.value,
32
+ "target": call.target,
33
+ "arguments": call.arguments,
34
+ },
35
+ ensure_ascii=False,
36
+ sort_keys=True,
37
+ separators=(",", ":"),
38
+ )
39
+ return hashlib.sha256(material.encode()).hexdigest()
40
+
41
+
42
+ @dataclass
43
+ class RequestStates:
44
+ """State rows, in the store the deployment already supplies."""
45
+
46
+ store: SessionStore
47
+ ttl_seconds: int = DEFAULT_TTL_SECONDS
48
+
49
+ def key(self, state_id: str) -> str:
50
+ return scoped(PREFIX + state_id)
51
+
52
+ async def open(self, call: Call, payload: Any) -> str:
53
+ """Store payload and return its opaque client-visible id."""
54
+ state_id = secrets.token_urlsafe(24)
55
+ created = await self.store.create(
56
+ self.key(state_id),
57
+ {PAYLOAD_KEY: payload, BINDING_KEY: binding_of(call)},
58
+ ttl_seconds=self.ttl_seconds,
59
+ )
60
+ if not created:
61
+ raise RuntimeError("the store refused a fresh request-state id")
62
+ return state_id
63
+
64
+ async def read(self, call: Call, state_id: str) -> Any:
65
+ """Read the payload; raise KeyError for missing state or a different call binding."""
66
+ record = await self.store.get(self.key(state_id))
67
+ if record is None or record.data.get(BINDING_KEY) != binding_of(call):
68
+ raise KeyError(state_id)
69
+ return record.data.get(PAYLOAD_KEY)
70
+
71
+ async def drop(self, state_id: str) -> None:
72
+ await self.store.delete(self.key(state_id))
73
+
74
+
75
+ async def restore_request_state(call: Call, states: RequestStates) -> Failure | None:
76
+ """Replace the id the client sent with the state the handler left."""
77
+ if call.state is None:
78
+ return None
79
+ if not isinstance(call.state, str):
80
+ return Failure(FailureKind.INVALID_PARAMS, "invalid requestState")
81
+ try:
82
+ call.state = await states.read(call, call.state)
83
+ except KeyError:
84
+ return Failure(FailureKind.INVALID_PARAMS, "invalid requestState")
85
+ return None
86
+
87
+
88
+ async def protect_request_state(
89
+ call: Call, outcome: NeedsInput, states: RequestStates
90
+ ) -> NeedsInput | Failure:
91
+ """Keep the handler's state and hand the client its id instead."""
92
+ if outcome.state is None:
93
+ return outcome
94
+ try:
95
+ state_id = await states.open(call, outcome.state)
96
+ except (RuntimeError, TypeError) as e:
97
+ return Failure(FailureKind.INTERNAL, f"requestState could not be stored: {e}")
98
+ return NeedsInput(requests=outcome.requests, state=state_id)
99
+
100
+
101
+ __all__ = [
102
+ "DEFAULT_TTL_SECONDS",
103
+ "RequestStates",
104
+ "binding_of",
105
+ "protect_request_state",
106
+ "restore_request_state",
107
+ ]
@@ -0,0 +1,131 @@
1
+ """JSON Schema generation and simplification for legacy clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import re
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+ HEADER_ANNOTATION = "x-mcp-header"
12
+ HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
13
+ HeaderParam = tuple[tuple[str, ...], str]
14
+
15
+
16
+ def json_schema(model: type[BaseModel]) -> dict:
17
+ """Generate JSON Schema 2020-12 with refs intact; legacy adapters simplify it separately."""
18
+ schema = model.model_json_schema()
19
+ schema.setdefault("$schema", "https://json-schema.org/draft/2020-12/schema")
20
+ schema.setdefault("type", "object")
21
+ schema.setdefault("properties", {})
22
+ return schema
23
+
24
+
25
+ def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
26
+ """Inline refs for legacy clients. Recursive schemas are not supported."""
27
+ defs = schema.get("$defs", {})
28
+
29
+ def walk(node: Any) -> Any:
30
+ if isinstance(node, dict):
31
+ ref = node.get("$ref")
32
+ if isinstance(ref, str) and ref.startswith("#/$defs/"):
33
+ target = walk(copy.deepcopy(defs[ref.rsplit("/", 1)[1]]))
34
+ return {**target, **{k: v for k, v in node.items() if k != "$ref"}}
35
+ return {k: walk(v) for k, v in node.items() if k != "$defs"}
36
+ if isinstance(node, list):
37
+ return [walk(x) for x in node]
38
+ return node
39
+
40
+ return walk(schema)
41
+
42
+
43
+ def header_params(schema: dict[str, Any]) -> tuple[HeaderParam, ...]:
44
+ """Return (property_path, header_suffix) pairs for reachable x-mcp-header declarations. Reject
45
+ invalid declarations at registration.
46
+ """
47
+ found: list[HeaderParam] = []
48
+ seen: set[str] = set()
49
+ definitions = schema.get("$defs", {})
50
+
51
+ def walk(node: Any, path: tuple[str, ...], active_refs: frozenset[str] = frozenset()) -> None:
52
+ if not isinstance(node, dict):
53
+ return
54
+ ref = node.get("$ref")
55
+ if isinstance(ref, str) and ref.startswith("#/$defs/") and ref not in active_refs:
56
+ target = definitions.get(ref.rsplit("/", 1)[1])
57
+ if isinstance(target, dict):
58
+ walk(
59
+ {**target, **{key: value for key, value in node.items() if key != "$ref"}},
60
+ path,
61
+ active_refs | {ref},
62
+ )
63
+ return
64
+ properties = node.get("properties")
65
+ if not isinstance(properties, dict):
66
+ return
67
+ for property_name, property_schema in properties.items():
68
+ if not isinstance(property_schema, dict):
69
+ continue
70
+ property_path = (*path, property_name)
71
+ annotation = property_schema.get(HEADER_ANNOTATION)
72
+ if annotation is not None:
73
+ if (
74
+ not isinstance(annotation, str)
75
+ or not annotation
76
+ or not HEADER_NAME.fullmatch(annotation)
77
+ ):
78
+ raise ValueError(f"invalid x-mcp-header value at {'.'.join(property_path)}")
79
+ if property_schema.get("type") not in {"string", "integer", "boolean"}:
80
+ raise ValueError(
81
+ f"x-mcp-header requires string, integer, or boolean at "
82
+ f"{'.'.join(property_path)}"
83
+ )
84
+ folded = annotation.casefold()
85
+ if folded in seen:
86
+ raise ValueError(f"duplicate x-mcp-header value: {annotation}")
87
+ seen.add(folded)
88
+ found.append((property_path, annotation))
89
+ walk(property_schema, property_path, active_refs)
90
+
91
+ walk(schema, ())
92
+ return tuple(found)
93
+
94
+
95
+ def simplify_legacy_schema(schema: dict) -> dict | None:
96
+ """Best-effort schema simplification for pre-2026-07-28 clients."""
97
+ node = _simplify_node(inline_refs(schema))
98
+ if node is None or node.get("type") != "object":
99
+ return None
100
+ return node
101
+
102
+
103
+ def _simplify_node(node: Any) -> Any:
104
+ if not isinstance(node, dict):
105
+ return node
106
+ node = {k: v for k, v in node.items() if k not in ("if", "then", "else")}
107
+ any_of = node.get("anyOf")
108
+ if isinstance(any_of, list):
109
+ branches = [
110
+ branch
111
+ for branch in any_of
112
+ if not (isinstance(branch, dict) and branch.get("type") == "null")
113
+ ]
114
+ if len(branches) != 1:
115
+ return None
116
+ branch = _simplify_node(branches[0])
117
+ if branch is None:
118
+ return None
119
+ node = {**{k: v for k, v in node.items() if k != "anyOf"}, **branch}
120
+ if "oneOf" in node:
121
+ return None
122
+ properties = node.get("properties")
123
+ if isinstance(properties, dict):
124
+ simplified = {}
125
+ for key, prop in properties.items():
126
+ prop = _simplify_node(prop)
127
+ if prop is None:
128
+ return None
129
+ simplified[key] = prop
130
+ node = {**node, "properties": simplified}
131
+ return node