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.
Files changed (46) hide show
  1. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/PKG-INFO +1 -1
  2. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/pyproject.toml +1 -1
  3. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/pyproject.toml.orig +1 -1
  4. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/adapter.py +3 -5
  5. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/console.js +34 -9
  6. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/dispatcher.py +6 -1
  7. aiohttp_tiny_mcp-0.3.3/src/aiohttp_tiny_mcp/extensions.py +279 -0
  8. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/registry.py +30 -6
  9. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/specs.py +1 -0
  10. aiohttp_tiny_mcp-0.3.2/src/aiohttp_tiny_mcp/extensions.py +0 -143
  11. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/LICENSE +0 -0
  12. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/README.md +0 -0
  13. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/__init__.py +0 -0
  14. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/auth.py +0 -0
  15. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/client.py +0 -0
  16. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/client_base.py +0 -0
  17. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  18. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/console.css +0 -0
  19. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/console/index.html +0 -0
  20. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/core.py +0 -0
  21. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/endpoint.py +0 -0
  22. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/hub.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/models.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/postgres.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/py.typed +0 -0
  36. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/redis.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/schema.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/skills.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/sse.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
  46. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.3}/src/aiohttp_tiny_mcp/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiohttp-tiny-mcp
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Remote MCP server and client library for aiohttp
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Remote MCP server and client library for aiohttp"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.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
- if (skill.resources === "dynamic") {
1165
- report("Files", "This skill has a dynamic manifest. Read its files by URI in Resources.");
1166
- return;
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 result = await verifiedSkillFile(await source.readResource(file.uri), file);
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=ResultModel.model_validate(dict(value)))
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
- manifest = extension.manifest_resource()
88
+ routes, methods = extension.method_resources()
89
+ manifest = extension.manifest_resource(routes, methods)
89
90
  assert manifest.definition is not None
90
- resources = {**extension.resources, manifest.definition.uri: manifest}
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
- self.resources_fixed[uri] = resource
108
- if resource.legacy_uri is not None:
109
- self.resource_aliases[resource.legacy_uri] = resource
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 and item.template.uri_template == template
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
- )