aiohttp-tiny-mcp 0.3.2__tar.gz → 0.3.3__tar.gz
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-0.3.2 → aiohttp_tiny_mcp-0.3.3}/PKG-INFO +1 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/adapter.py +3 -5
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/console.js +34 -9
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/dispatcher.py +6 -1
- aiohttp_tiny_mcp-0.3.3/src/aiohttp_tiny_mcp/extensions.py +279 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/registry.py +30 -6
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/specs.py +1 -0
- aiohttp_tiny_mcp-0.3.2/src/aiohttp_tiny_mcp/extensions.py +0 -143
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/README.md +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/auth.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/client.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/client_base.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/console.css +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/index.html +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/endpoint.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/models.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/postgres.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/redis.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/stdio.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/testing.py +0 -0
|
@@ -174,11 +174,7 @@ class Adapter(ABC):
|
|
|
174
174
|
if msg.is_notification:
|
|
175
175
|
raise Rejected(Failure(FailureKind.MALFORMED, "extension requests require an id"))
|
|
176
176
|
call.target = msg.method
|
|
177
|
-
call.arguments = {
|
|
178
|
-
key: value
|
|
179
|
-
for key, value in msg.params.items()
|
|
180
|
-
if key not in {"_meta", "inputResponses", "requestState"}
|
|
181
|
-
}
|
|
177
|
+
call.arguments = {key: value for key, value in msg.params.items() if key != "_meta"}
|
|
182
178
|
return call
|
|
183
179
|
|
|
184
180
|
def build_call(self, operation: Operation, msg: Incoming, params: Params) -> Call:
|
|
@@ -331,6 +327,8 @@ class Adapter(ABC):
|
|
|
331
327
|
return None
|
|
332
328
|
if spec.definition is not None and spec.legacy_uri and not self.supports_extensions:
|
|
333
329
|
return spec.definition.model_copy(update={"uri": spec.legacy_uri})
|
|
330
|
+
if spec.template is not None and spec.legacy_uri and not self.supports_extensions:
|
|
331
|
+
return spec.template.model_copy(update={"uri_template": spec.legacy_uri})
|
|
334
332
|
return spec.definition if spec.definition is not None else spec.template
|
|
335
333
|
|
|
336
334
|
def describe_prompt(self, spec: PromptSpec) -> PromptDef | None:
|
|
@@ -1124,7 +1124,18 @@ function report(title, body, failed) {
|
|
|
1124
1124
|
page.outcome.append(card);
|
|
1125
1125
|
}
|
|
1126
1126
|
|
|
1127
|
+
function resourceUris(value, found = new Set()) {
|
|
1128
|
+
if (!value || typeof value !== "object") return found;
|
|
1129
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1130
|
+
if (key === "uri" && typeof child === "string" && /^[a-z][a-z0-9+.-]*:/i.test(child)) {
|
|
1131
|
+
found.add(child);
|
|
1132
|
+
} else if (child && typeof child === "object") resourceUris(child, found);
|
|
1133
|
+
}
|
|
1134
|
+
return found;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1127
1137
|
function reportContents(result) {
|
|
1138
|
+
const links = new Set();
|
|
1128
1139
|
(result.contents || []).forEach((part) => {
|
|
1129
1140
|
if (part.text === undefined) {
|
|
1130
1141
|
reportValue(part.mimeType || "contents", part);
|
|
@@ -1135,8 +1146,23 @@ function reportContents(result) {
|
|
|
1135
1146
|
report(part.mimeType || "contents", part.text);
|
|
1136
1147
|
} else {
|
|
1137
1148
|
reportValue(part.mimeType || "contents", held);
|
|
1149
|
+
resourceUris(held, links);
|
|
1138
1150
|
}
|
|
1139
1151
|
});
|
|
1152
|
+
if (links.size) {
|
|
1153
|
+
const group = element("div", "group");
|
|
1154
|
+
group.append(element("h4", null, "Read a resource"));
|
|
1155
|
+
links.forEach((uri) => {
|
|
1156
|
+
const button = element("button", "item", uri);
|
|
1157
|
+
button.type = "button";
|
|
1158
|
+
button.onclick = () => {
|
|
1159
|
+
choose({ kind: "resource", item: { uri } });
|
|
1160
|
+
invoke();
|
|
1161
|
+
};
|
|
1162
|
+
group.append(button);
|
|
1163
|
+
});
|
|
1164
|
+
page.outcome.append(group);
|
|
1165
|
+
}
|
|
1140
1166
|
}
|
|
1141
1167
|
|
|
1142
1168
|
async function verifiedSkillFile(result, file) {
|
|
@@ -1161,23 +1187,22 @@ async function verifiedSkillFile(result, file) {
|
|
|
1161
1187
|
|
|
1162
1188
|
function reportSkill(skill) {
|
|
1163
1189
|
reportValue("Frontmatter", skill.frontmatter);
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
if (!Array.isArray(skill.resources)) throw new Error("The skill has no file manifest.");
|
|
1169
|
-
reportValue("File manifest", skill.resources);
|
|
1190
|
+
const dynamic = skill.resources === "dynamic";
|
|
1191
|
+
if (!dynamic && !Array.isArray(skill.resources)) throw new Error("The skill has no file manifest.");
|
|
1192
|
+
if (dynamic) report("Files", "Read the current instructions below. This dynamic manifest provides no file digests.");
|
|
1193
|
+
else reportValue("File manifest", skill.resources);
|
|
1170
1194
|
const files = element("div", "group");
|
|
1171
1195
|
files.append(element("h4", null, "Read a file"));
|
|
1172
1196
|
const selected = chosen;
|
|
1173
1197
|
const source = client;
|
|
1174
|
-
skill.resources.forEach((file) => {
|
|
1175
|
-
const button = element("button", "item", `${file.uri} (${file.size} bytes)`);
|
|
1198
|
+
(dynamic ? [{ uri: skill.uri }] : skill.resources).forEach((file) => {
|
|
1199
|
+
const button = element("button", "item", dynamic ? file.uri : `${file.uri} (${file.size} bytes)`);
|
|
1176
1200
|
button.type = "button";
|
|
1177
1201
|
button.onclick = async () => {
|
|
1178
1202
|
button.disabled = true;
|
|
1179
1203
|
try {
|
|
1180
|
-
const
|
|
1204
|
+
const content = await source.readResource(file.uri);
|
|
1205
|
+
const result = dynamic ? content : await verifiedSkillFile(content, file);
|
|
1181
1206
|
if (chosen === selected && client === source) reportContents(result);
|
|
1182
1207
|
} catch (error) {
|
|
1183
1208
|
if (chosen === selected && client === source) report("Could not read file", error.message, true);
|
|
@@ -22,6 +22,7 @@ from .core import (
|
|
|
22
22
|
Value,
|
|
23
23
|
)
|
|
24
24
|
from .exchange import Exchange
|
|
25
|
+
from .extensions import ExtensionResult
|
|
25
26
|
from .models import (
|
|
26
27
|
CallToolResult,
|
|
27
28
|
CompleteResult,
|
|
@@ -212,7 +213,7 @@ class Dispatcher:
|
|
|
212
213
|
value = value.model_dump(mode="json", by_alias=True)
|
|
213
214
|
if not isinstance(value, Mapping):
|
|
214
215
|
raise TypeError(f"{method}: extension handler must return a result object")
|
|
215
|
-
return Value(result=
|
|
216
|
+
return Value(result=ExtensionResult.model_validate(dict(value)))
|
|
216
217
|
return Failure(FailureKind.UNKNOWN_METHOD, f"unknown method: {method}")
|
|
217
218
|
|
|
218
219
|
async def set_log_level(self, ex: Exchange) -> Outcome:
|
|
@@ -345,6 +346,10 @@ class Dispatcher:
|
|
|
345
346
|
definition = ex.adapter.describe_resource(spec)
|
|
346
347
|
if definition is None or (isinstance(definition, ResourceDef) and definition.uri != uri):
|
|
347
348
|
return Failure(FailureKind.RESOURCE_NOT_FOUND, f"resource not found: {uri}")
|
|
349
|
+
if spec.template is not None and spec.legacy_uri is not None:
|
|
350
|
+
pattern = spec.pattern if ex.adapter.supports_extensions else spec.legacy_pattern
|
|
351
|
+
if pattern is None or pattern.fullmatch(uri) is None:
|
|
352
|
+
return Failure(FailureKind.RESOURCE_NOT_FOUND, f"resource not found: {uri}")
|
|
348
353
|
try:
|
|
349
354
|
value = await spec.bound.call(variables, ex)
|
|
350
355
|
except ValidationError as e:
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Reusable extension declarations for MCP 2026-07-28 and later."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import unquote
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from .core import Failure, FailureKind, Rejected
|
|
16
|
+
from .models import ResultModel
|
|
17
|
+
from .schema import json_schema
|
|
18
|
+
from .specs import Bound, ResourceSpec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ExtensionSpec:
|
|
23
|
+
capabilities: Mapping[str, Any]
|
|
24
|
+
min_revision: str
|
|
25
|
+
methods: Mapping[str, Bound]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExtensionResult(ResultModel):
|
|
29
|
+
"""Preserve result discriminators defined by an extension."""
|
|
30
|
+
|
|
31
|
+
result_type: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Nothing(BaseModel):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LegacyParams(BaseModel):
|
|
39
|
+
params: str = "{}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def map_resource_uris(value: Any, resources: list[ResourceSpec], *, legacy: bool) -> Any:
|
|
43
|
+
"""Translate URI fields without changing opaque cursors or file contents."""
|
|
44
|
+
if isinstance(value, list):
|
|
45
|
+
return [map_resource_uris(item, resources, legacy=legacy) for item in value]
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
return value
|
|
48
|
+
result = {}
|
|
49
|
+
for key, item in value.items():
|
|
50
|
+
if key == "uri" and isinstance(item, str):
|
|
51
|
+
for spec in resources:
|
|
52
|
+
assert spec.legacy_uri is not None
|
|
53
|
+
if spec.definition is not None:
|
|
54
|
+
canonical = spec.definition.uri
|
|
55
|
+
else:
|
|
56
|
+
assert spec.template is not None
|
|
57
|
+
canonical = spec.template.uri_template
|
|
58
|
+
source, target = (
|
|
59
|
+
(canonical, spec.legacy_uri) if legacy else (spec.legacy_uri, canonical)
|
|
60
|
+
)
|
|
61
|
+
pattern = spec.pattern if legacy else spec.legacy_pattern
|
|
62
|
+
if item == source:
|
|
63
|
+
item = target
|
|
64
|
+
break
|
|
65
|
+
if pattern is not None and (match := pattern.fullmatch(item)):
|
|
66
|
+
item = ResourceSpec.VAR.sub(lambda m: match[m[1]], target)
|
|
67
|
+
break
|
|
68
|
+
result[key] = item
|
|
69
|
+
else:
|
|
70
|
+
result[key] = map_resource_uris(item, resources, legacy=legacy)
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Extension:
|
|
75
|
+
"""Bundle methods, capability settings, and resources for registration.
|
|
76
|
+
|
|
77
|
+
Configure the extension before passing it to ``registry.extension()``.
|
|
78
|
+
Each registry takes a snapshot of the declarations.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
name: str,
|
|
84
|
+
*,
|
|
85
|
+
capabilities: Mapping[str, Any] | None = None,
|
|
86
|
+
min_revision: str = "2026-07-28",
|
|
87
|
+
) -> None:
|
|
88
|
+
if (
|
|
89
|
+
not name
|
|
90
|
+
or "/" not in name
|
|
91
|
+
or any(part in ("", ".", "..") for part in name.split("/"))
|
|
92
|
+
or any(char.isspace() or char in "?#%:\\" for char in name)
|
|
93
|
+
):
|
|
94
|
+
raise ValueError("extension name must be a namespaced identifier")
|
|
95
|
+
if min_revision < "2026-07-28":
|
|
96
|
+
raise ValueError("extensions require MCP 2026-07-28 or later")
|
|
97
|
+
self.name = name
|
|
98
|
+
self.capabilities = deepcopy(dict(capabilities or {}))
|
|
99
|
+
self.min_revision = min_revision
|
|
100
|
+
self.methods: dict[str, Bound] = {}
|
|
101
|
+
self.resources: dict[str, ResourceSpec] = {}
|
|
102
|
+
|
|
103
|
+
def method(self, name: str, fn: Callable[..., Awaitable[Any]] | None = None):
|
|
104
|
+
"""Register an async handler whose first argument is a Pydantic model.
|
|
105
|
+
|
|
106
|
+
Return a result mapping or a ``ResultModel`` subclass. Additional
|
|
107
|
+
parameters use the registry's dependency providers, including Exchange.
|
|
108
|
+
Older revisions invoke the same handler through a namespaced resource.
|
|
109
|
+
Parameters use a percent-encoded JSON query value. Reading a method
|
|
110
|
+
resource can change state, depending on the handler.
|
|
111
|
+
"""
|
|
112
|
+
if not name or name.startswith("rpc.") or any(char.isspace() for char in name):
|
|
113
|
+
raise ValueError(f"invalid extension method: {name!r}")
|
|
114
|
+
if (
|
|
115
|
+
any(part in ("", ".", "..") for part in name.split("/"))
|
|
116
|
+
or any(char in "?#%:{}\\" for char in name)
|
|
117
|
+
or name == "manifest.json"
|
|
118
|
+
):
|
|
119
|
+
raise ValueError(f"invalid legacy method path: {name!r}")
|
|
120
|
+
|
|
121
|
+
def register(fn: Callable[..., Awaitable[Any]]):
|
|
122
|
+
if name in self.methods:
|
|
123
|
+
raise ValueError(f"duplicate extension method: {name}")
|
|
124
|
+
self.methods[name] = Bound.of(fn)
|
|
125
|
+
return fn
|
|
126
|
+
|
|
127
|
+
return register(fn) if fn is not None else register
|
|
128
|
+
|
|
129
|
+
def resource(
|
|
130
|
+
self,
|
|
131
|
+
uri: str,
|
|
132
|
+
fn: Callable[..., Awaitable[Any]] | None = None,
|
|
133
|
+
*,
|
|
134
|
+
legacy_path: str | None = None,
|
|
135
|
+
**kw: Any,
|
|
136
|
+
):
|
|
137
|
+
"""Bundle a resource with a legacy ``mcp-extensions://{name}/...`` URI.
|
|
138
|
+
|
|
139
|
+
By default, the legacy path is the URI after its scheme. Set
|
|
140
|
+
``legacy_path`` to choose a different relative path within the extension.
|
|
141
|
+
"""
|
|
142
|
+
path = legacy_path if legacy_path is not None else uri.split("://", 1)[-1]
|
|
143
|
+
if not path or any(part in ("", ".", "..") for part in path.split("/")):
|
|
144
|
+
raise ValueError("legacy_path must be a relative resource path")
|
|
145
|
+
if path == "manifest.json":
|
|
146
|
+
raise ValueError("manifest.json is reserved for the extension manifest")
|
|
147
|
+
|
|
148
|
+
def register(fn: Callable[..., Awaitable[Any]]):
|
|
149
|
+
if uri in self.resources:
|
|
150
|
+
raise ValueError(f"duplicate extension resource: {uri}")
|
|
151
|
+
spec = ResourceSpec.build(uri, fn, **kw)
|
|
152
|
+
spec.legacy_uri = f"mcp-extensions://{self.name}/{path}"
|
|
153
|
+
alias = ResourceSpec.build(spec.legacy_uri, fn)
|
|
154
|
+
if set(ResourceSpec.VAR.findall(uri)) != set(ResourceSpec.VAR.findall(path)):
|
|
155
|
+
raise ValueError("legacy_path must use the same template variables as the URI")
|
|
156
|
+
spec.legacy_pattern = alias.pattern
|
|
157
|
+
self.resources[uri] = spec
|
|
158
|
+
return fn
|
|
159
|
+
|
|
160
|
+
return register(fn) if fn is not None else register
|
|
161
|
+
|
|
162
|
+
def method_resources(self) -> tuple[dict[str, ResourceSpec], dict[str, Any]]:
|
|
163
|
+
"""Build legacy resource routes around every registered method."""
|
|
164
|
+
from .exchange import Exchange
|
|
165
|
+
|
|
166
|
+
resources = [spec.model_copy(deep=True) for spec in self.resources.values()]
|
|
167
|
+
routes: dict[str, ResourceSpec] = {}
|
|
168
|
+
entries = {}
|
|
169
|
+
|
|
170
|
+
def reader(bound: Bound):
|
|
171
|
+
async def read(args: LegacyParams, ex) -> dict:
|
|
172
|
+
try:
|
|
173
|
+
params = json.loads(unquote(args.params))
|
|
174
|
+
except (ValueError, UnicodeError) as error:
|
|
175
|
+
raise Rejected(
|
|
176
|
+
Failure(FailureKind.INVALID_PARAMS, "Invalid JSON params")
|
|
177
|
+
) from error
|
|
178
|
+
if not isinstance(params, dict):
|
|
179
|
+
raise Rejected(Failure(FailureKind.INVALID_PARAMS, "params must be an object"))
|
|
180
|
+
params = map_resource_uris(params, resources, legacy=False)
|
|
181
|
+
value = await bound.call(params, ex)
|
|
182
|
+
if isinstance(value, BaseModel):
|
|
183
|
+
value = value.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
184
|
+
if not isinstance(value, Mapping):
|
|
185
|
+
raise TypeError("extension handler must return a result object")
|
|
186
|
+
return map_resource_uris(dict(value), resources, legacy=True)
|
|
187
|
+
|
|
188
|
+
read.__annotations__["ex"] = Exchange
|
|
189
|
+
return read
|
|
190
|
+
|
|
191
|
+
for name in sorted(self.methods):
|
|
192
|
+
bound = self.methods[name].model_copy(deep=True)
|
|
193
|
+
uri = f"mcp-extensions://{self.name}/{name}"
|
|
194
|
+
schema = json_schema(bound.args_model)
|
|
195
|
+
entry = {
|
|
196
|
+
"uriTemplate": uri + "?params={params}",
|
|
197
|
+
"inputSchema": schema,
|
|
198
|
+
"description": (bound.fn.__doc__ or "").strip(),
|
|
199
|
+
}
|
|
200
|
+
if isinstance(bound.returns, type) and issubclass(bound.returns, BaseModel):
|
|
201
|
+
entry["outputSchema"] = json_schema(bound.returns)
|
|
202
|
+
if not schema.get("required"):
|
|
203
|
+
entry["uri"] = uri
|
|
204
|
+
handler = reader(bound)
|
|
205
|
+
for address in [entry["uriTemplate"], *([uri] if "uri" in entry else [])]:
|
|
206
|
+
spec = ResourceSpec.build(
|
|
207
|
+
address,
|
|
208
|
+
handler,
|
|
209
|
+
name=name,
|
|
210
|
+
mime_type="application/json",
|
|
211
|
+
description=(
|
|
212
|
+
f"Invoke {name}; this can change state. "
|
|
213
|
+
"params is a percent-encoded JSON object. "
|
|
214
|
+
+ (bound.fn.__doc__ or "").strip()
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
spec.legacy_only = True
|
|
218
|
+
routes[address] = spec
|
|
219
|
+
entries[name] = entry
|
|
220
|
+
return routes, entries
|
|
221
|
+
|
|
222
|
+
def manifest_resource(
|
|
223
|
+
self, routes: dict[str, ResourceSpec] | None = None, methods: dict[str, Any] | None = None
|
|
224
|
+
) -> ResourceSpec:
|
|
225
|
+
"""Describe the extension to clients that only support resources."""
|
|
226
|
+
manifest = {
|
|
227
|
+
"name": self.name,
|
|
228
|
+
"instructions": (
|
|
229
|
+
"Use resources/read to invoke a method URI. For arguments, substitute "
|
|
230
|
+
"percent-encoded JSON for {params} in its uriTemplate. Each read invokes "
|
|
231
|
+
"the handler and can change state. Results are JSON resource contents; "
|
|
232
|
+
"failures are JSON-RPC errors. Follow returned resource URIs to read files."
|
|
233
|
+
),
|
|
234
|
+
"capabilities": self.capabilities,
|
|
235
|
+
"methods": sorted(self.methods),
|
|
236
|
+
"resources": sorted(
|
|
237
|
+
[
|
|
238
|
+
spec.legacy_uri
|
|
239
|
+
for spec in self.resources.values()
|
|
240
|
+
if spec.legacy_uri is not None and spec.definition is not None
|
|
241
|
+
]
|
|
242
|
+
+ [uri for uri, spec in (routes or {}).items() if spec.definition is not None]
|
|
243
|
+
),
|
|
244
|
+
}
|
|
245
|
+
templates = sorted(
|
|
246
|
+
[
|
|
247
|
+
spec.legacy_uri
|
|
248
|
+
for spec in self.resources.values()
|
|
249
|
+
if spec.template is not None and spec.legacy_uri is not None
|
|
250
|
+
]
|
|
251
|
+
+ [uri for uri, spec in (routes or {}).items() if spec.template is not None]
|
|
252
|
+
)
|
|
253
|
+
if templates:
|
|
254
|
+
manifest["resourceTemplates"] = templates
|
|
255
|
+
if methods:
|
|
256
|
+
manifest["methodResources"] = methods
|
|
257
|
+
content = json.dumps(manifest, ensure_ascii=False)
|
|
258
|
+
|
|
259
|
+
async def read(args: Nothing) -> str:
|
|
260
|
+
return content
|
|
261
|
+
|
|
262
|
+
spec = ResourceSpec.build(
|
|
263
|
+
f"mcp-extensions://{self.name}/manifest.json",
|
|
264
|
+
read,
|
|
265
|
+
name=f"{self.name} manifest",
|
|
266
|
+
mime_type="application/json",
|
|
267
|
+
description="Compatibility resource routes, parameter schemas, and file URIs.",
|
|
268
|
+
)
|
|
269
|
+
spec.legacy_only = True
|
|
270
|
+
return spec
|
|
271
|
+
|
|
272
|
+
def snapshot(self) -> ExtensionSpec:
|
|
273
|
+
return ExtensionSpec(
|
|
274
|
+
capabilities=MappingProxyType(deepcopy(self.capabilities)),
|
|
275
|
+
min_revision=self.min_revision,
|
|
276
|
+
methods=MappingProxyType(
|
|
277
|
+
{name: bound.model_copy(deep=True) for name, bound in self.methods.items()}
|
|
278
|
+
),
|
|
279
|
+
)
|
|
@@ -85,9 +85,21 @@ class Registry:
|
|
|
85
85
|
if any(name in spec.methods for spec in self.extensions.values()):
|
|
86
86
|
raise ValueError(f"duplicate extension method: {name}")
|
|
87
87
|
self.check(name, bound)
|
|
88
|
-
|
|
88
|
+
routes, methods = extension.method_resources()
|
|
89
|
+
manifest = extension.manifest_resource(routes, methods)
|
|
89
90
|
assert manifest.definition is not None
|
|
90
|
-
|
|
91
|
+
if manifest.definition.uri in extension.resources:
|
|
92
|
+
raise ValueError("duplicate extension manifest resource")
|
|
93
|
+
if set(routes) & set(extension.resources):
|
|
94
|
+
raise ValueError("duplicate extension method resource")
|
|
95
|
+
resources = {**extension.resources, **routes, manifest.definition.uri: manifest}
|
|
96
|
+
templates = {
|
|
97
|
+
address
|
|
98
|
+
for spec in self.resources_templated
|
|
99
|
+
if spec.template is not None
|
|
100
|
+
for address in (spec.template.uri_template, spec.legacy_uri)
|
|
101
|
+
if address is not None
|
|
102
|
+
}
|
|
91
103
|
claimed: set[str] = set()
|
|
92
104
|
for uri, resource in resources.items():
|
|
93
105
|
for address in {uri, resource.legacy_uri}:
|
|
@@ -97,6 +109,7 @@ class Registry:
|
|
|
97
109
|
address in claimed
|
|
98
110
|
or address in self.resources_fixed
|
|
99
111
|
or address in self.resource_aliases
|
|
112
|
+
or address in templates
|
|
100
113
|
):
|
|
101
114
|
raise ValueError(f"duplicate resource: {address}")
|
|
102
115
|
claimed.add(address)
|
|
@@ -104,9 +117,17 @@ class Registry:
|
|
|
104
117
|
self.extensions[extension.name] = extension.snapshot()
|
|
105
118
|
for uri, resource in resources.items():
|
|
106
119
|
resource = resource.model_copy(deep=True)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
if resource.definition is not None:
|
|
121
|
+
self.resources_fixed[uri] = resource
|
|
122
|
+
if resource.legacy_uri is not None:
|
|
123
|
+
self.resource_aliases[resource.legacy_uri] = resource
|
|
124
|
+
else:
|
|
125
|
+
if resource.legacy_only:
|
|
126
|
+
# Method query routes take precedence over file templates
|
|
127
|
+
# such as skills/{name}, whose matcher also accepts '?'.
|
|
128
|
+
self.resources_templated.insert(0, resource)
|
|
129
|
+
else:
|
|
130
|
+
self.resources_templated.append(resource)
|
|
110
131
|
|
|
111
132
|
def provide(self, kind: type, source: Any) -> None:
|
|
112
133
|
"""Bind a type to where it comes from. Call before registering
|
|
@@ -176,7 +197,8 @@ class Registry:
|
|
|
176
197
|
else:
|
|
177
198
|
template = spec.template.uri_template if spec.template is not None else uri
|
|
178
199
|
if any(
|
|
179
|
-
item.template is not None
|
|
200
|
+
item.template is not None
|
|
201
|
+
and template in (item.template.uri_template, item.legacy_uri)
|
|
180
202
|
for item in self.resources_templated
|
|
181
203
|
):
|
|
182
204
|
raise ValueError(f"duplicate resource template: {template}")
|
|
@@ -210,6 +232,8 @@ class Registry:
|
|
|
210
232
|
return fixed, {}
|
|
211
233
|
for spec in self.resources_templated:
|
|
212
234
|
m = spec.pattern.match(uri)
|
|
235
|
+
if m is None and spec.legacy_pattern is not None:
|
|
236
|
+
m = spec.legacy_pattern.match(uri)
|
|
213
237
|
if m:
|
|
214
238
|
return spec, m.groupdict()
|
|
215
239
|
return None
|
|
@@ -153,6 +153,7 @@ class ResourceSpec(Model):
|
|
|
153
153
|
cache_ttl_ms: int | None = None
|
|
154
154
|
cache_scope: Literal["public", "private"] | None = None
|
|
155
155
|
legacy_uri: str | None = None
|
|
156
|
+
legacy_pattern: Any = None
|
|
156
157
|
legacy_only: bool = False
|
|
157
158
|
|
|
158
159
|
VAR: ClassVar[re.Pattern] = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
"""Reusable extension declarations for MCP 2026-07-28 and later."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
-
from copy import deepcopy
|
|
8
|
-
from dataclasses import dataclass
|
|
9
|
-
from types import MappingProxyType
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
from pydantic import BaseModel
|
|
13
|
-
|
|
14
|
-
from .specs import Bound, ResourceSpec
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass(frozen=True)
|
|
18
|
-
class ExtensionSpec:
|
|
19
|
-
capabilities: Mapping[str, Any]
|
|
20
|
-
min_revision: str
|
|
21
|
-
methods: Mapping[str, Bound]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class Nothing(BaseModel):
|
|
25
|
-
pass
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Extension:
|
|
29
|
-
"""Bundle methods, capability settings, and resources for registration.
|
|
30
|
-
|
|
31
|
-
Configure the extension before passing it to ``registry.extension()``.
|
|
32
|
-
Each registry takes a snapshot of the declarations.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
def __init__(
|
|
36
|
-
self,
|
|
37
|
-
name: str,
|
|
38
|
-
*,
|
|
39
|
-
capabilities: Mapping[str, Any] | None = None,
|
|
40
|
-
min_revision: str = "2026-07-28",
|
|
41
|
-
) -> None:
|
|
42
|
-
if (
|
|
43
|
-
not name
|
|
44
|
-
or "/" not in name
|
|
45
|
-
or any(part in ("", ".", "..") for part in name.split("/"))
|
|
46
|
-
or any(char.isspace() or char in "?#%:\\" for char in name)
|
|
47
|
-
):
|
|
48
|
-
raise ValueError("extension name must be a namespaced identifier")
|
|
49
|
-
if min_revision < "2026-07-28":
|
|
50
|
-
raise ValueError("extensions require MCP 2026-07-28 or later")
|
|
51
|
-
self.name = name
|
|
52
|
-
self.capabilities = deepcopy(dict(capabilities or {}))
|
|
53
|
-
self.min_revision = min_revision
|
|
54
|
-
self.methods: dict[str, Bound] = {}
|
|
55
|
-
self.resources: dict[str, ResourceSpec] = {}
|
|
56
|
-
|
|
57
|
-
def method(self, name: str, fn: Callable[..., Awaitable[Any]] | None = None):
|
|
58
|
-
"""Register an async handler whose first argument is a Pydantic model.
|
|
59
|
-
|
|
60
|
-
Return a result mapping or a ``ResultModel`` subclass. Additional
|
|
61
|
-
parameters use the registry's dependency providers, including Exchange.
|
|
62
|
-
"""
|
|
63
|
-
if not name or name.startswith("rpc.") or any(char.isspace() for char in name):
|
|
64
|
-
raise ValueError(f"invalid extension method: {name!r}")
|
|
65
|
-
|
|
66
|
-
def register(fn: Callable[..., Awaitable[Any]]):
|
|
67
|
-
if name in self.methods:
|
|
68
|
-
raise ValueError(f"duplicate extension method: {name}")
|
|
69
|
-
self.methods[name] = Bound.of(fn)
|
|
70
|
-
return fn
|
|
71
|
-
|
|
72
|
-
return register(fn) if fn is not None else register
|
|
73
|
-
|
|
74
|
-
def resource(
|
|
75
|
-
self,
|
|
76
|
-
uri: str,
|
|
77
|
-
fn: Callable[..., Awaitable[Any]] | None = None,
|
|
78
|
-
*,
|
|
79
|
-
legacy_path: str | None = None,
|
|
80
|
-
**kw: Any,
|
|
81
|
-
):
|
|
82
|
-
"""Bundle a resource with a legacy ``mcp-extenstion://{name}/...`` URI.
|
|
83
|
-
|
|
84
|
-
By default, the legacy path is the URI after its scheme. Set
|
|
85
|
-
``legacy_path`` to choose a different relative path within the extension.
|
|
86
|
-
"""
|
|
87
|
-
path = legacy_path if legacy_path is not None else uri.split("://", 1)[-1]
|
|
88
|
-
if not path or any(part in ("", ".", "..") for part in path.split("/")):
|
|
89
|
-
raise ValueError("legacy_path must be a relative resource path")
|
|
90
|
-
if path == "manifest.json":
|
|
91
|
-
raise ValueError("manifest.json is reserved for the extension manifest")
|
|
92
|
-
|
|
93
|
-
def register(fn: Callable[..., Awaitable[Any]]):
|
|
94
|
-
if uri in self.resources:
|
|
95
|
-
raise ValueError(f"duplicate extension resource: {uri}")
|
|
96
|
-
spec = ResourceSpec.build(uri, fn, **kw)
|
|
97
|
-
if spec.definition is None:
|
|
98
|
-
raise ValueError("extension resources must use fixed URIs")
|
|
99
|
-
spec.legacy_uri = f"mcp-extenstion://{self.name}/{path}"
|
|
100
|
-
self.resources[uri] = spec
|
|
101
|
-
return fn
|
|
102
|
-
|
|
103
|
-
return register(fn) if fn is not None else register
|
|
104
|
-
|
|
105
|
-
def manifest_resource(self) -> ResourceSpec:
|
|
106
|
-
"""Describe the extension to clients that only support resources."""
|
|
107
|
-
content = json.dumps(
|
|
108
|
-
{
|
|
109
|
-
"name": self.name,
|
|
110
|
-
"capabilities": self.capabilities,
|
|
111
|
-
"methods": sorted(self.methods),
|
|
112
|
-
"resources": sorted(
|
|
113
|
-
spec.legacy_uri
|
|
114
|
-
for spec in self.resources.values()
|
|
115
|
-
if spec.legacy_uri is not None
|
|
116
|
-
),
|
|
117
|
-
},
|
|
118
|
-
ensure_ascii=False,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
async def read(args: Nothing) -> str:
|
|
122
|
-
return content
|
|
123
|
-
|
|
124
|
-
spec = ResourceSpec.build(
|
|
125
|
-
f"mcp-extenstion://{self.name}/manifest.json",
|
|
126
|
-
read,
|
|
127
|
-
name=f"{self.name} manifest",
|
|
128
|
-
mime_type="application/json",
|
|
129
|
-
description=(
|
|
130
|
-
f"Extension metadata and resource URIs. Methods require MCP {self.min_revision}+."
|
|
131
|
-
),
|
|
132
|
-
)
|
|
133
|
-
spec.legacy_only = True
|
|
134
|
-
return spec
|
|
135
|
-
|
|
136
|
-
def snapshot(self) -> ExtensionSpec:
|
|
137
|
-
return ExtensionSpec(
|
|
138
|
-
capabilities=MappingProxyType(deepcopy(self.capabilities)),
|
|
139
|
-
min_revision=self.min_revision,
|
|
140
|
-
methods=MappingProxyType(
|
|
141
|
-
{name: bound.model_copy(deep=True) for name, bound in self.methods.items()}
|
|
142
|
-
),
|
|
143
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|