aiohttp-tiny-mcp 0.3.2__tar.gz → 0.3.4__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.4}/PKG-INFO +6 -2
  2. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/README.md +5 -1
  3. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/pyproject.toml +1 -1
  4. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/pyproject.toml.orig +1 -1
  5. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/adapter.py +3 -5
  6. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/console.css +2 -0
  7. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/console.js +75 -12
  8. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/index.html +3 -3
  9. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/dispatcher.py +6 -1
  10. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/endpoint.py +25 -2
  11. aiohttp_tiny_mcp-0.3.4/src/aiohttp_tiny_mcp/extensions.py +279 -0
  12. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/registry.py +30 -6
  13. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/specs.py +1 -0
  14. aiohttp_tiny_mcp-0.3.2/src/aiohttp_tiny_mcp/extensions.py +0 -143
  15. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/LICENSE +0 -0
  16. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/__init__.py +0 -0
  17. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/auth.py +0 -0
  18. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/client.py +0 -0
  19. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/client_base.py +0 -0
  20. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
  21. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/core.py +0 -0
  22. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/exchange.py +0 -0
  23. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
  24. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/hub.py +0 -0
  25. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/models.py +0 -0
  26. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
  27. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/postgres.py +0 -0
  28. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  29. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
  30. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
  31. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
  32. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  33. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
  34. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
  35. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/py.typed +0 -0
  36. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/redis.py +0 -0
  37. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/request_state.py +0 -0
  38. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/schema.py +0 -0
  39. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/sessions.py +0 -0
  40. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/skills.py +0 -0
  41. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
  42. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/sse.py +0 -0
  43. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/stdio.py +0 -0
  44. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
  45. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
  46. {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/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.4
4
4
  Summary: Remote MCP server and client library for aiohttp
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -146,7 +146,11 @@ async def deploy(args: Deploy, ex: Exchange) -> str:
146
146
  return f"deployed {args.service}"
147
147
 
148
148
 
149
- app = Endpoint(registry).app("/mcp")
149
+ app = Endpoint(
150
+ registry,
151
+ # Behind a reverse proxy, uncomment this and let the proxy check Origin.
152
+ # trust_proxy_origin_validation=True,
153
+ ).app("/mcp")
150
154
 
151
155
  if __name__ == "__main__":
152
156
  web.run_app(app, host="127.0.0.1", port=8080)
@@ -104,7 +104,11 @@ async def deploy(args: Deploy, ex: Exchange) -> str:
104
104
  return f"deployed {args.service}"
105
105
 
106
106
 
107
- app = Endpoint(registry).app("/mcp")
107
+ app = Endpoint(
108
+ registry,
109
+ # Behind a reverse proxy, uncomment this and let the proxy check Origin.
110
+ # trust_proxy_origin_validation=True,
111
+ ).app("/mcp")
108
112
 
109
113
  if __name__ == "__main__":
110
114
  web.run_app(app, host="127.0.0.1", port=8080)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aiohttp-tiny-mcp"
3
- version = "0.3.2"
3
+ version = "0.3.4"
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.4"
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:
@@ -71,6 +71,8 @@ body {
71
71
  }
72
72
 
73
73
  .field { display: flex; align-items: center; gap: 6px; }
74
+ .field.grow { flex: 1 1 12em; min-width: 0; }
75
+ .field.grow > input { flex: 1 1 auto; min-width: 8em; }
74
76
  .field > span { color: var(--ink-soft); font-size: 12px; }
75
77
 
76
78
  input[type="text"], input[type="number"], select, textarea {
@@ -384,6 +384,34 @@ const page = {
384
384
  let client = null;
385
385
  let chosen = null;
386
386
  let endpointUrl = null;
387
+ //: Where the page was told the endpoint is. The field starts from it and may add a query.
388
+ let configuredEndpoint = null;
389
+
390
+ // The endpoint field: the path, plus any query string a person adds, such as
391
+ // `?mcp=2025-06-18` to pin a revision or whatever the server reads from the URL.
392
+ // The value is kept for the next visit while its path is still the configured one,
393
+ // so a server moved elsewhere is not chased at a stale address.
394
+ function endpointKey() {
395
+ return `mcp-console-endpoint:${location.pathname}`;
396
+ }
397
+
398
+ function rememberedEndpoint() {
399
+ const kept = localStorage.getItem(endpointKey());
400
+ if (!kept) return null;
401
+ const url = new URL(kept, location.origin);
402
+ return url.pathname === configuredEndpoint.pathname ? kept : null;
403
+ }
404
+
405
+ function chosenEndpoint() {
406
+ const typed = page.endpoint.value.trim() || configuredEndpoint.pathname;
407
+ const url = new URL(typed, location.origin);
408
+ if (url.origin !== location.origin) {
409
+ throw new Error(`The endpoint must be on this server, not ${url.origin}.`);
410
+ }
411
+ page.endpoint.value = url.pathname + url.search;
412
+ localStorage.setItem(endpointKey(), page.endpoint.value);
413
+ return url;
414
+ }
387
415
 
388
416
  function element(tag, className, text) {
389
417
  const made = document.createElement(tag);
@@ -1124,7 +1152,18 @@ function report(title, body, failed) {
1124
1152
  page.outcome.append(card);
1125
1153
  }
1126
1154
 
1155
+ function resourceUris(value, found = new Set()) {
1156
+ if (!value || typeof value !== "object") return found;
1157
+ for (const [key, child] of Object.entries(value)) {
1158
+ if (key === "uri" && typeof child === "string" && /^[a-z][a-z0-9+.-]*:/i.test(child)) {
1159
+ found.add(child);
1160
+ } else if (child && typeof child === "object") resourceUris(child, found);
1161
+ }
1162
+ return found;
1163
+ }
1164
+
1127
1165
  function reportContents(result) {
1166
+ const links = new Set();
1128
1167
  (result.contents || []).forEach((part) => {
1129
1168
  if (part.text === undefined) {
1130
1169
  reportValue(part.mimeType || "contents", part);
@@ -1135,8 +1174,23 @@ function reportContents(result) {
1135
1174
  report(part.mimeType || "contents", part.text);
1136
1175
  } else {
1137
1176
  reportValue(part.mimeType || "contents", held);
1177
+ resourceUris(held, links);
1138
1178
  }
1139
1179
  });
1180
+ if (links.size) {
1181
+ const group = element("div", "group");
1182
+ group.append(element("h4", null, "Read a resource"));
1183
+ links.forEach((uri) => {
1184
+ const button = element("button", "item", uri);
1185
+ button.type = "button";
1186
+ button.onclick = () => {
1187
+ choose({ kind: "resource", item: { uri } });
1188
+ invoke();
1189
+ };
1190
+ group.append(button);
1191
+ });
1192
+ page.outcome.append(group);
1193
+ }
1140
1194
  }
1141
1195
 
1142
1196
  async function verifiedSkillFile(result, file) {
@@ -1161,23 +1215,22 @@ async function verifiedSkillFile(result, file) {
1161
1215
 
1162
1216
  function reportSkill(skill) {
1163
1217
  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);
1218
+ const dynamic = skill.resources === "dynamic";
1219
+ if (!dynamic && !Array.isArray(skill.resources)) throw new Error("The skill has no file manifest.");
1220
+ if (dynamic) report("Files", "Read the current instructions below. This dynamic manifest provides no file digests.");
1221
+ else reportValue("File manifest", skill.resources);
1170
1222
  const files = element("div", "group");
1171
1223
  files.append(element("h4", null, "Read a file"));
1172
1224
  const selected = chosen;
1173
1225
  const source = client;
1174
- skill.resources.forEach((file) => {
1175
- const button = element("button", "item", `${file.uri} (${file.size} bytes)`);
1226
+ (dynamic ? [{ uri: skill.uri }] : skill.resources).forEach((file) => {
1227
+ const button = element("button", "item", dynamic ? file.uri : `${file.uri} (${file.size} bytes)`);
1176
1228
  button.type = "button";
1177
1229
  button.onclick = async () => {
1178
1230
  button.disabled = true;
1179
1231
  try {
1180
- const result = await verifiedSkillFile(await source.readResource(file.uri), file);
1232
+ const content = await source.readResource(file.uri);
1233
+ const result = dynamic ? content : await verifiedSkillFile(content, file);
1181
1234
  if (chosen === selected && client === source) reportContents(result);
1182
1235
  } catch (error) {
1183
1236
  if (chosen === selected && client === source) report("Could not read file", error.message, true);
@@ -1272,6 +1325,7 @@ function disconnect() {
1272
1325
  page.refresh.disabled = true;
1273
1326
  page.revision.disabled = false;
1274
1327
  page.answerable.disabled = false;
1328
+ page.endpoint.disabled = false;
1275
1329
  page.connect.textContent = "Connect";
1276
1330
  say("not connected", "");
1277
1331
  }
@@ -1280,9 +1334,11 @@ async function connect() {
1280
1334
  page.connect.disabled = true;
1281
1335
  page.revision.disabled = true;
1282
1336
  page.answerable.disabled = true;
1337
+ page.endpoint.disabled = true;
1283
1338
  say("connecting", "");
1284
1339
  page.outcome.textContent = "";
1285
1340
  try {
1341
+ endpointUrl = chosenEndpoint();
1286
1342
  client = new Client(endpointUrl.href, page.revision.value, {
1287
1343
  answerable: page.answerable.checked,
1288
1344
  onFrame: record,
@@ -1305,6 +1361,7 @@ async function connect() {
1305
1361
  page.refresh.disabled = true;
1306
1362
  page.revision.disabled = false;
1307
1363
  page.answerable.disabled = false;
1364
+ page.endpoint.disabled = false;
1308
1365
  page.connect.textContent = "Connect";
1309
1366
  say(error.message, "off");
1310
1367
  report("Could not connect", connectionHelp(error), true);
@@ -1315,7 +1372,7 @@ async function connect() {
1315
1372
 
1316
1373
  function connectionHelp(error) {
1317
1374
  const lines = [error.message];
1318
- if (error instanceof TypeError || /fetch/i.test(error.message)) {
1375
+ if (endpointUrl && (error instanceof TypeError || /fetch/i.test(error.message))) {
1319
1376
  lines.push("", `The request to ${endpointUrl.href} did not reach a server.`);
1320
1377
  }
1321
1378
  return lines.join("\n");
@@ -1384,8 +1441,14 @@ function start() {
1384
1441
  const remembered = localStorage.getItem("mcp-console-revision");
1385
1442
  page.revision.value = REVISIONS[remembered] ? remembered : Object.keys(REVISIONS)[0];
1386
1443
 
1387
- endpointUrl = new URL(document.documentElement.dataset.endpoint || "/mcp", location.origin);
1388
- page.endpoint.textContent = endpointUrl.pathname;
1444
+ configuredEndpoint = new URL(document.documentElement.dataset.endpoint || "/mcp", location.origin);
1445
+ page.endpoint.value = rememberedEndpoint() || configuredEndpoint.pathname + configuredEndpoint.search;
1446
+ page.endpoint.onkeydown = (event) => {
1447
+ if (event.key === "Enter" && !client) {
1448
+ event.preventDefault();
1449
+ connect();
1450
+ }
1451
+ };
1389
1452
 
1390
1453
  page.connect.onclick = () => (client ? disconnect() : connect());
1391
1454
  page.refresh.onclick = async () => {
@@ -11,10 +11,10 @@
11
11
 
12
12
  <header class="bar">
13
13
  <h1>{{TITLE}}</h1>
14
- <span class="field">
14
+ <label class="field grow" title="The path the server serves MCP on. A query string is sent as typed">
15
15
  <span>Endpoint</span>
16
- <code id="endpoint" class="endpoint"></code>
17
- </span>
16
+ <input id="endpoint" class="endpoint" type="text" spellcheck="false" autocomplete="off">
17
+ </label>
18
18
  <label class="field">
19
19
  <span>Revision</span>
20
20
  <select id="revision" class="revision"></select>
@@ -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:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
+ import re
8
9
  from collections.abc import Mapping
9
10
  from contextlib import suppress
10
11
  from dataclasses import replace
@@ -51,6 +52,24 @@ log = logging.getLogger("aiohttp_tiny_mcp")
51
52
  MAY_ASK = frozenset({Operation.CALL_TOOL, Operation.GET_PROMPT, Operation.READ_RESOURCE})
52
53
 
53
54
 
55
+ def origin_pattern(spec: str) -> re.Pattern[str]:
56
+ """Compile an allowed origin with wildcards.
57
+
58
+ `*` matches one host label, `**` matches one or more labels, so
59
+ `https://*.example.com` admits `https://app.example.com` and `https://**.example.com`
60
+ admits `https://a.b.example.com` as well. Everything else matches literally.
61
+ """
62
+ parts = []
63
+ for piece in re.split(r"(\*\*|\*)", spec):
64
+ if piece == "**":
65
+ parts.append(r"[^./]+(?:\.[^./]+)*")
66
+ elif piece == "*":
67
+ parts.append(r"[^./]+")
68
+ else:
69
+ parts.append(re.escape(piece))
70
+ return re.compile("".join(parts))
71
+
72
+
54
73
  class Endpoint:
55
74
  """The MCP endpoint, in the spec's sense: one path that accepts POST.
56
75
 
@@ -81,6 +100,7 @@ class Endpoint:
81
100
  self.adapters = adapters or AdapterSet.default()
82
101
  self.dispatcher = Dispatcher(registry)
83
102
  self.allowed_origins = allowed_origins
103
+ self.origin_patterns = [origin_pattern(o) for o in allowed_origins or () if "*" in o]
84
104
  self.trust_proxy_origin_validation = trust_proxy_origin_validation
85
105
  self.compress = compress
86
106
 
@@ -187,8 +207,11 @@ class Endpoint:
187
207
  return
188
208
  if origin == self.own_origin(request):
189
209
  return
190
- if self.allowed_origins is None or origin not in self.allowed_origins:
191
- raise Rejected(Failure(FailureKind.ORIGIN_REJECTED, "origin not allowed"))
210
+ if self.allowed_origins is not None and origin in self.allowed_origins:
211
+ return
212
+ if any(pattern.fullmatch(origin) for pattern in self.origin_patterns):
213
+ return
214
+ raise Rejected(Failure(FailureKind.ORIGIN_REJECTED, "origin not allowed"))
192
215
 
193
216
  def own_origin(self, request: web.Request) -> str:
194
217
  """Origin of pages served by this endpoint. A rebound page retains the attacker's origin
@@ -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
- )