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.
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/PKG-INFO +6 -2
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/README.md +5 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/pyproject.toml +1 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/pyproject.toml.orig +1 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/adapter.py +3 -5
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/console.css +2 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/console.js +75 -12
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/index.html +3 -3
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/dispatcher.py +6 -1
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/endpoint.py +25 -2
- aiohttp_tiny_mcp-0.3.4/src/aiohttp_tiny_mcp/extensions.py +279 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/registry.py +30 -6
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/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.4}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/auth.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/client.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/client_base.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/console/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/core.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/exchange.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/http_sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/hub.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/models.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/namespaces.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/postgres.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/selection.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/redis.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/request_state.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/schema.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/sessions.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/skills.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/sqlite.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/sse.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/stdio.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/stdio_client.py +0 -0
- {aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/subscriptions.py +0 -0
- {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.
|
|
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(
|
|
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(
|
|
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)
|
|
@@ -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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
|
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
|
-
|
|
1388
|
-
page.endpoint.
|
|
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
|
-
<
|
|
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
|
-
<
|
|
17
|
-
</
|
|
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=
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
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
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/selection.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py
RENAMED
|
File without changes
|
{aiohttp_tiny_mcp-0.3.2 → aiohttp_tiny_mcp-0.3.4}/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
|