aiohttp-tiny-mcp 0.4.0__tar.gz → 0.5.0__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.4.0 → aiohttp_tiny_mcp-0.5.0}/PKG-INFO +7 -5
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/README.md +4 -4
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/pyproject.toml +7 -2
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/pyproject.toml.orig +8 -4
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/__init__.py +30 -17
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/auth.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/auth/__init__.py +123 -18
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/auth/jwt.py +117 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/client/__init__.py +1 -0
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/client_base.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/client/base.py +3 -3
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/client.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/client/http.py +6 -5
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/stdio_client.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/client/stdio.py +4 -3
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/__init__.py +21 -1
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/console.css +9 -2
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/console.js +420 -18
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/index.html +36 -1
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/console/oauth-callback.html +14 -0
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/extensions.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/extensions/__init__.py +5 -5
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/extensions}/skills.py +3 -3
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/__init__.py +19 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/github.py +50 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/provider.py +136 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/server.py +547 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/tokens.py +220 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/protocol/__init__.py +1 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/protocol}/adapter.py +4 -3
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/selection.py +2 -2
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +3 -3
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +3 -3
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +5 -5
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +5 -5
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server/__init__.py +1 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/dispatcher.py +6 -5
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/exchange.py +7 -7
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/endpoint.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server/http.py +19 -15
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/registry.py +7 -6
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/request_state.py +3 -3
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/specs.py +2 -2
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/http_sse.py → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server/sse.py +13 -12
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/stdio.py +4 -3
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/subscriptions.py +3 -3
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage/__init__.py +1 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage}/namespaces.py +2 -1
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage}/postgres.py +1 -1
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage}/redis.py +1 -1
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage}/sessions.py +23 -4
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage}/sqlite.py +1 -1
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/testing.py +9 -9
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/transport/__init__.py +1 -0
- aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/transport/metadata.py +44 -0
- aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/LICENSE +0 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/protocol}/core.py +0 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/protocol}/models.py +0 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/protocol}/schema.py +0 -0
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
- {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/py.typed +0 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server}/tasks.py +0 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage}/hub.py +0 -0
- {aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp → aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/transport}/sse.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiohttp-tiny-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Remote MCP server and client library for aiohttp
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -26,6 +26,7 @@ Classifier: Topic :: System :: Distributed Computing
|
|
|
26
26
|
Classifier: Typing :: Typed
|
|
27
27
|
Requires-Dist: aiohttp>=3.14
|
|
28
28
|
Requires-Dist: pydantic>=2.13
|
|
29
|
+
Requires-Dist: pyjwt[crypto]>=2.13 ; extra == 'jwt'
|
|
29
30
|
Requires-Dist: psycopg[binary,pool]>=3 ; extra == 'postgres'
|
|
30
31
|
Requires-Dist: redis>=5 ; extra == 'redis'
|
|
31
32
|
Requires-Dist: pyyaml>=6 ; extra == 'skills'
|
|
@@ -34,6 +35,7 @@ Requires-Python: >=3.10
|
|
|
34
35
|
Project-URL: Documentation, https://mosquito.github.io/aiohttp-tiny-mcp/
|
|
35
36
|
Project-URL: Repository, https://github.com/mosquito/aiohttp-tiny-mcp
|
|
36
37
|
Project-URL: Issues, https://github.com/mosquito/aiohttp-tiny-mcp/issues
|
|
38
|
+
Provides-Extra: jwt
|
|
37
39
|
Provides-Extra: postgres
|
|
38
40
|
Provides-Extra: redis
|
|
39
41
|
Provides-Extra: skills
|
|
@@ -80,7 +82,7 @@ For a local multi-process setup:
|
|
|
80
82
|
from aiohttp import web
|
|
81
83
|
|
|
82
84
|
from aiohttp_tiny_mcp import Registry
|
|
83
|
-
from aiohttp_tiny_mcp.sqlite import SqliteHub, SqliteSessionStore, SqliteStorage
|
|
85
|
+
from aiohttp_tiny_mcp.storage.sqlite import SqliteHub, SqliteSessionStore, SqliteStorage
|
|
84
86
|
|
|
85
87
|
storage = SqliteStorage("mcp.sqlite")
|
|
86
88
|
registry = Registry(
|
|
@@ -173,8 +175,8 @@ Console("/mcp", title="Demo").setup(app, "/console")
|
|
|
173
175
|
|
|
174
176
|
Open `http://127.0.0.1:8080/console`. It speaks the protocol itself on any of
|
|
175
177
|
the five revisions, builds a form from each tool's schema, answers the
|
|
176
|
-
questions a handler asks, and shows every message either way.
|
|
177
|
-
this package
|
|
178
|
+
questions a handler asks, and shows every message either way. Static files from
|
|
179
|
+
this package need no build step or second process.
|
|
178
180
|
|
|
179
181
|
An MCP host that supports Streamable HTTP can connect to
|
|
180
182
|
`http://127.0.0.1:8080/mcp`. In an existing aiohttp service, use
|
|
@@ -235,7 +237,7 @@ Start with [the documentation overview](https://mosquito.github.io/aiohttp-tiny-
|
|
|
235
237
|
2. [Quickstart](https://mosquito.github.io/aiohttp-tiny-mcp/quickstart.html): a complete server, launch command, and client call.
|
|
236
238
|
3. [How the server fits together](https://mosquito.github.io/aiohttp-tiny-mcp/pieces.html): a conversation across two workers and each object's lifetime.
|
|
237
239
|
4. [Using Exchange](https://mosquito.github.io/aiohttp-tiny-mcp/guide/exchange.html): request context, progress, questions, and state.
|
|
238
|
-
5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer,
|
|
240
|
+
5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer, GitHub sign-in, custom policies, and permissions.
|
|
239
241
|
6. [Stores and hubs](https://mosquito.github.io/aiohttp-tiny-mcp/deployment/stores.html): shared backend contracts and deployment requirements.
|
|
240
242
|
7. [Extensions and skills](https://mosquito.github.io/aiohttp-tiny-mcp/guide/extensions.html): custom methods, skill directories, and resources for older clients.
|
|
241
243
|
|
|
@@ -38,7 +38,7 @@ For a local multi-process setup:
|
|
|
38
38
|
from aiohttp import web
|
|
39
39
|
|
|
40
40
|
from aiohttp_tiny_mcp import Registry
|
|
41
|
-
from aiohttp_tiny_mcp.sqlite import SqliteHub, SqliteSessionStore, SqliteStorage
|
|
41
|
+
from aiohttp_tiny_mcp.storage.sqlite import SqliteHub, SqliteSessionStore, SqliteStorage
|
|
42
42
|
|
|
43
43
|
storage = SqliteStorage("mcp.sqlite")
|
|
44
44
|
registry = Registry(
|
|
@@ -131,8 +131,8 @@ Console("/mcp", title="Demo").setup(app, "/console")
|
|
|
131
131
|
|
|
132
132
|
Open `http://127.0.0.1:8080/console`. It speaks the protocol itself on any of
|
|
133
133
|
the five revisions, builds a form from each tool's schema, answers the
|
|
134
|
-
questions a handler asks, and shows every message either way.
|
|
135
|
-
this package
|
|
134
|
+
questions a handler asks, and shows every message either way. Static files from
|
|
135
|
+
this package need no build step or second process.
|
|
136
136
|
|
|
137
137
|
An MCP host that supports Streamable HTTP can connect to
|
|
138
138
|
`http://127.0.0.1:8080/mcp`. In an existing aiohttp service, use
|
|
@@ -193,7 +193,7 @@ Start with [the documentation overview](https://mosquito.github.io/aiohttp-tiny-
|
|
|
193
193
|
2. [Quickstart](https://mosquito.github.io/aiohttp-tiny-mcp/quickstart.html): a complete server, launch command, and client call.
|
|
194
194
|
3. [How the server fits together](https://mosquito.github.io/aiohttp-tiny-mcp/pieces.html): a conversation across two workers and each object's lifetime.
|
|
195
195
|
4. [Using Exchange](https://mosquito.github.io/aiohttp-tiny-mcp/guide/exchange.html): request context, progress, questions, and state.
|
|
196
|
-
5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer,
|
|
196
|
+
5. [Authentication](https://mosquito.github.io/aiohttp-tiny-mcp/guide/auth.html): Basic, Bearer, GitHub sign-in, custom policies, and permissions.
|
|
197
197
|
6. [Stores and hubs](https://mosquito.github.io/aiohttp-tiny-mcp/deployment/stores.html): shared backend contracts and deployment requirements.
|
|
198
198
|
7. [Extensions and skills](https://mosquito.github.io/aiohttp-tiny-mcp/guide/extensions.html): custom methods, skill directories, and resources for older clients.
|
|
199
199
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aiohttp-tiny-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Remote MCP server and client library for aiohttp"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -39,6 +39,7 @@ Repository = "https://github.com/mosquito/aiohttp-tiny-mcp"
|
|
|
39
39
|
Issues = "https://github.com/mosquito/aiohttp-tiny-mcp/issues"
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
|
+
jwt = ["PyJWT[crypto]>=2.13"]
|
|
42
43
|
skills = ["PyYAML>=6"]
|
|
43
44
|
sqlite = ["aiosqlite>=0.20"]
|
|
44
45
|
redis = ["redis>=5"]
|
|
@@ -47,7 +48,7 @@ postgres = ["psycopg[binary,pool]>=3"]
|
|
|
47
48
|
[dependency-groups]
|
|
48
49
|
dev = [
|
|
49
50
|
"PyYAML>=6",
|
|
50
|
-
"PyJWT>=2.13",
|
|
51
|
+
"PyJWT[crypto]>=2.13",
|
|
51
52
|
"aiosqlite>=0.20",
|
|
52
53
|
"furo>=2025.12.19",
|
|
53
54
|
"markdown-pytest>=0.6.6",
|
|
@@ -101,8 +102,12 @@ select = [
|
|
|
101
102
|
"I",
|
|
102
103
|
"UP",
|
|
103
104
|
"B",
|
|
105
|
+
"TID",
|
|
104
106
|
]
|
|
105
107
|
|
|
108
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
109
|
+
ban-relative-imports = "parents"
|
|
110
|
+
|
|
106
111
|
[tool.ty.environment]
|
|
107
112
|
root = [
|
|
108
113
|
"src",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aiohttp-tiny-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Remote MCP server and client library for aiohttp"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -39,8 +39,9 @@ Repository = "https://github.com/mosquito/aiohttp-tiny-mcp"
|
|
|
39
39
|
Issues = "https://github.com/mosquito/aiohttp-tiny-mcp/issues"
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
|
+
jwt = ["PyJWT[crypto]>=2.13"]
|
|
42
43
|
skills = ["PyYAML>=6"]
|
|
43
|
-
# A hub and a session store on one SQLite file: aiohttp_tiny_mcp.sqlite.
|
|
44
|
+
# A hub and a session store on one SQLite file: aiohttp_tiny_mcp.storage.sqlite.
|
|
44
45
|
sqlite = ["aiosqlite>=0.20"]
|
|
45
46
|
redis = [
|
|
46
47
|
"redis>=5",
|
|
@@ -52,7 +53,7 @@ postgres = [
|
|
|
52
53
|
[dependency-groups]
|
|
53
54
|
dev = [
|
|
54
55
|
"PyYAML>=6",
|
|
55
|
-
"PyJWT>=2.13",
|
|
56
|
+
"PyJWT[crypto]>=2.13",
|
|
56
57
|
"aiosqlite>=0.20",
|
|
57
58
|
"furo>=2025.12.19",
|
|
58
59
|
"markdown-pytest>=0.6.6",
|
|
@@ -104,7 +105,10 @@ line-length = 100
|
|
|
104
105
|
target-version = "py310"
|
|
105
106
|
|
|
106
107
|
[tool.ruff.lint]
|
|
107
|
-
select = ["E", "F", "I", "UP", "B"]
|
|
108
|
+
select = ["E", "F", "I", "UP", "B", "TID"]
|
|
109
|
+
|
|
110
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
111
|
+
ban-relative-imports = "parents"
|
|
108
112
|
|
|
109
113
|
[tool.ty.environment]
|
|
110
114
|
root = ["src", "examples"]
|
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from .auth import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
from .auth import (
|
|
6
|
+
Authentication,
|
|
7
|
+
Authorization,
|
|
8
|
+
BasicAuth,
|
|
9
|
+
Principal,
|
|
10
|
+
StaticBasicAuth,
|
|
11
|
+
StaticVerifier,
|
|
12
|
+
TokenVerifier,
|
|
13
|
+
Unauthorized,
|
|
14
|
+
principal_from_claims,
|
|
15
|
+
)
|
|
16
|
+
from .client.base import ClientError, Elicitor
|
|
17
|
+
from .client.http import Client
|
|
18
|
+
from .client.stdio import StdioClient
|
|
19
|
+
from .extensions import Extension
|
|
20
|
+
from .protocol.core import (
|
|
9
21
|
Answer,
|
|
10
22
|
AnswerAction,
|
|
11
23
|
NeedInput,
|
|
@@ -14,12 +26,7 @@ from .core import (
|
|
|
14
26
|
elicit_cancel,
|
|
15
27
|
elicit_decline,
|
|
16
28
|
)
|
|
17
|
-
from .
|
|
18
|
-
from .exchange import Exchange
|
|
19
|
-
from .extensions import Extension
|
|
20
|
-
from .http_sse import SseEndpoint
|
|
21
|
-
from .hub import Hub, MemoryHub
|
|
22
|
-
from .models import (
|
|
29
|
+
from .protocol.models import (
|
|
23
30
|
AudioContent,
|
|
24
31
|
BlobResourceContents,
|
|
25
32
|
CallToolResult,
|
|
@@ -34,14 +41,17 @@ from .models import (
|
|
|
34
41
|
TextContent,
|
|
35
42
|
TextResourceContents,
|
|
36
43
|
)
|
|
37
|
-
from .namespaces import namespace
|
|
38
44
|
from .protocol.selection import AdapterSet
|
|
39
|
-
from .
|
|
40
|
-
from .
|
|
41
|
-
from .
|
|
42
|
-
from .
|
|
43
|
-
from .
|
|
44
|
-
from .
|
|
45
|
+
from .server.exchange import Exchange
|
|
46
|
+
from .server.http import Endpoint
|
|
47
|
+
from .server.registry import Registry
|
|
48
|
+
from .server.request_state import RequestStates
|
|
49
|
+
from .server.sse import SseEndpoint
|
|
50
|
+
from .server.stdio import run_stdio, serve_stdio
|
|
51
|
+
from .storage.hub import Hub, MemoryHub
|
|
52
|
+
from .storage.namespaces import namespace
|
|
53
|
+
from .storage.sessions import MemorySessionStore, SessionRecord, SessionStore
|
|
54
|
+
from .transport.sse import SSEResponse
|
|
45
55
|
|
|
46
56
|
__all__ = [
|
|
47
57
|
"AdapterSet",
|
|
@@ -81,6 +91,8 @@ __all__ = [
|
|
|
81
91
|
"SessionStore",
|
|
82
92
|
"StdioClient",
|
|
83
93
|
"StaticBasicAuth",
|
|
94
|
+
"StaticVerifier",
|
|
95
|
+
"TokenVerifier",
|
|
84
96
|
"TextContent",
|
|
85
97
|
"TextResourceContents",
|
|
86
98
|
"Unauthorized",
|
|
@@ -88,6 +100,7 @@ __all__ = [
|
|
|
88
100
|
"elicit_accept",
|
|
89
101
|
"elicit_cancel",
|
|
90
102
|
"elicit_decline",
|
|
103
|
+
"principal_from_claims",
|
|
91
104
|
"run_stdio",
|
|
92
105
|
"serve_stdio",
|
|
93
106
|
]
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import math
|
|
5
6
|
import time
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
8
|
from base64 import b64decode
|
|
8
9
|
from binascii import Error as Base64Error
|
|
9
10
|
from collections.abc import Iterable, Mapping, Sequence
|
|
10
11
|
from dataclasses import dataclass, field
|
|
12
|
+
from hashlib import sha256
|
|
11
13
|
from secrets import compare_digest
|
|
12
14
|
from typing import Any, Protocol, runtime_checkable
|
|
13
15
|
from urllib.parse import urlsplit
|
|
@@ -52,6 +54,73 @@ class TokenVerifier(Protocol):
|
|
|
52
54
|
async def verify(self, token: str) -> Principal | None: ...
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
def principal_from_claims(claims: Mapping[str, Any]) -> Principal:
|
|
58
|
+
"""Map verified claims to a principal. This function does not verify tokens.
|
|
59
|
+
|
|
60
|
+
Empty claims are valid. Malformed identity, scope, or expiry values raise
|
|
61
|
+
ValueError. Application-specific namespaces require a custom mapper.
|
|
62
|
+
"""
|
|
63
|
+
subject = claims.get("sub")
|
|
64
|
+
issuer = claims.get("iss")
|
|
65
|
+
client_id = claims.get("client_id", claims.get("azp", ""))
|
|
66
|
+
if subject is not None and not isinstance(subject, str):
|
|
67
|
+
raise ValueError("sub must be a string")
|
|
68
|
+
if issuer is not None and not isinstance(issuer, str):
|
|
69
|
+
raise ValueError("iss must be a string")
|
|
70
|
+
if not isinstance(client_id, str):
|
|
71
|
+
raise ValueError("client_id or azp must be a string")
|
|
72
|
+
scope = claims.get("scope", claims.get("scp", []))
|
|
73
|
+
if isinstance(scope, str):
|
|
74
|
+
scopes = frozenset(scope.split())
|
|
75
|
+
elif isinstance(scope, list) and all(isinstance(item, str) for item in scope):
|
|
76
|
+
scopes = frozenset(scope)
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError("scope or scp must be a string or a list of strings")
|
|
79
|
+
expires_at = None
|
|
80
|
+
if "exp" in claims:
|
|
81
|
+
try:
|
|
82
|
+
expires_at = float(claims["exp"])
|
|
83
|
+
except (ValueError, TypeError, OverflowError) as error:
|
|
84
|
+
raise ValueError("exp must be a finite timestamp") from error
|
|
85
|
+
if isinstance(claims["exp"], bool) or not math.isfinite(expires_at):
|
|
86
|
+
raise ValueError("exp must be a finite timestamp")
|
|
87
|
+
return Principal(
|
|
88
|
+
subject=subject,
|
|
89
|
+
issuer=issuer,
|
|
90
|
+
client_id=client_id,
|
|
91
|
+
scopes=scopes,
|
|
92
|
+
expires_at=expires_at,
|
|
93
|
+
claims=dict(claims),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StaticVerifier:
|
|
98
|
+
"""Verify configured opaque tokens for development and tests.
|
|
99
|
+
|
|
100
|
+
Compare fixed-size digests for every configured token, including after a
|
|
101
|
+
match. Authorization enforces the returned principal's expiry and scopes.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, mapping: Mapping[str, Principal]) -> None:
|
|
105
|
+
if any(not token for token in mapping):
|
|
106
|
+
raise ValueError("static tokens must be non-empty")
|
|
107
|
+
self._entries = tuple(
|
|
108
|
+
(sha256(token.encode("utf-8")).digest(), principal)
|
|
109
|
+
for token, principal in mapping.items()
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def verify(self, token: str) -> Principal | None:
|
|
113
|
+
try:
|
|
114
|
+
digest = sha256(token.encode("utf-8")).digest()
|
|
115
|
+
except UnicodeEncodeError:
|
|
116
|
+
return None
|
|
117
|
+
found = None
|
|
118
|
+
for expected, principal in self._entries:
|
|
119
|
+
if compare_digest(digest, expected):
|
|
120
|
+
found = principal
|
|
121
|
+
return found
|
|
122
|
+
|
|
123
|
+
|
|
55
124
|
class Unauthorized(Exception):
|
|
56
125
|
"""Authentication or authorization failed."""
|
|
57
126
|
|
|
@@ -121,7 +190,7 @@ class BasicAuth(Authentication):
|
|
|
121
190
|
"""Subclass `verify` to authenticate Basic credentials against your user store.
|
|
122
191
|
|
|
123
192
|
Credentials use standard Base64 and UTF-8. Query credentials are not read.
|
|
124
|
-
Use `StaticBasicAuth` for
|
|
193
|
+
Use `StaticBasicAuth` for configured accounts with individual scopes.
|
|
125
194
|
"""
|
|
126
195
|
|
|
127
196
|
def __init__(
|
|
@@ -165,15 +234,17 @@ class BasicAuth(Authentication):
|
|
|
165
234
|
|
|
166
235
|
|
|
167
236
|
class StaticBasicAuth(BasicAuth):
|
|
168
|
-
"""Basic authentication for
|
|
237
|
+
"""Basic authentication for configured accounts with individual scopes.
|
|
238
|
+
|
|
239
|
+
Pass each account as (username, password, scopes), or (username, password)
|
|
240
|
+
for an account without scopes. Usernames must be unique. Use HTTPS in
|
|
241
|
+
production.
|
|
242
|
+
"""
|
|
169
243
|
|
|
170
244
|
def __init__(
|
|
171
245
|
self,
|
|
172
|
-
|
|
173
|
-
password: str,
|
|
174
|
-
*,
|
|
246
|
+
*accounts: tuple[str, str] | tuple[str, str, Iterable[str]],
|
|
175
247
|
realm: str = "mcp",
|
|
176
|
-
scopes: Iterable[str] = (),
|
|
177
248
|
bind_sessions: bool = True,
|
|
178
249
|
namespace_from_token: bool = True,
|
|
179
250
|
required_scopes: Sequence[str] = (),
|
|
@@ -184,20 +255,52 @@ class StaticBasicAuth(BasicAuth):
|
|
|
184
255
|
namespace_from_token=namespace_from_token,
|
|
185
256
|
required_scopes=required_scopes,
|
|
186
257
|
)
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
258
|
+
if not accounts:
|
|
259
|
+
raise ValueError("at least one Basic account is required")
|
|
260
|
+
entries: list[tuple[bytes, bytes, Principal]] = []
|
|
261
|
+
usernames: set[str] = set()
|
|
262
|
+
for account in accounts:
|
|
263
|
+
if not isinstance(account, tuple) or len(account) not in (2, 3):
|
|
264
|
+
raise TypeError("accounts must be (username, password[, scopes]) tuples")
|
|
265
|
+
username, password = account[:2]
|
|
266
|
+
if not isinstance(username, str) or not isinstance(password, str):
|
|
267
|
+
raise TypeError("username and password must be strings")
|
|
268
|
+
if not username or not password:
|
|
269
|
+
raise ValueError("username and password must be non-empty")
|
|
270
|
+
if ":" in username or any(
|
|
271
|
+
ord(char) < 32 or ord(char) == 127 for char in username + password
|
|
272
|
+
):
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"Basic credentials cannot contain controls or a colon in the username"
|
|
275
|
+
)
|
|
276
|
+
if username in usernames:
|
|
277
|
+
raise ValueError("Basic account usernames must be unique")
|
|
278
|
+
usernames.add(username)
|
|
279
|
+
scopes = account[2] if len(account) == 3 else ()
|
|
280
|
+
if isinstance(scopes, str):
|
|
281
|
+
raise TypeError("account scopes must be an iterable of strings, not a string")
|
|
282
|
+
granted = frozenset(scopes)
|
|
283
|
+
if any(not isinstance(scope, str) for scope in granted):
|
|
284
|
+
raise TypeError("account scopes must contain only strings")
|
|
285
|
+
entries.append(
|
|
286
|
+
(
|
|
287
|
+
sha256(username.encode("utf-8")).digest(),
|
|
288
|
+
sha256(password.encode("utf-8")).digest(),
|
|
289
|
+
Principal(subject=username, scopes=granted),
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
self._entries = tuple(entries)
|
|
196
293
|
|
|
197
294
|
async def verify(self, username: str, password: str) -> Principal | None:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
295
|
+
given_username = sha256(username.encode("utf-8")).digest()
|
|
296
|
+
given_password = sha256(password.encode("utf-8")).digest()
|
|
297
|
+
found = None
|
|
298
|
+
for expected_username, expected_password, principal in self._entries:
|
|
299
|
+
matched = compare_digest(given_username, expected_username)
|
|
300
|
+
matched &= compare_digest(given_password, expected_password)
|
|
301
|
+
if matched:
|
|
302
|
+
found = principal
|
|
303
|
+
return found
|
|
201
304
|
|
|
202
305
|
|
|
203
306
|
@dataclass(frozen=True)
|
|
@@ -269,6 +372,8 @@ __all__ = [
|
|
|
269
372
|
"BasicAuth",
|
|
270
373
|
"Principal",
|
|
271
374
|
"StaticBasicAuth",
|
|
375
|
+
"StaticVerifier",
|
|
272
376
|
"TokenVerifier",
|
|
273
377
|
"Unauthorized",
|
|
378
|
+
"principal_from_claims",
|
|
274
379
|
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Optional JWT verification. Install aiohttp-tiny-mcp[jwt] to use this module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import jwt
|
|
12
|
+
from cryptography.exceptions import UnsupportedAlgorithm
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
|
|
15
|
+
from jwt.algorithms import HMACAlgorithm
|
|
16
|
+
except ImportError as error:
|
|
17
|
+
raise ImportError("JWT verification requires aiohttp-tiny-mcp[jwt]") from error
|
|
18
|
+
|
|
19
|
+
from aiohttp_tiny_mcp.auth import Principal, principal_from_claims
|
|
20
|
+
|
|
21
|
+
VerificationKey = (
|
|
22
|
+
bytes
|
|
23
|
+
| rsa.RSAPublicKey
|
|
24
|
+
| ec.EllipticCurvePublicKey
|
|
25
|
+
| ed25519.Ed25519PublicKey
|
|
26
|
+
| ed448.Ed448PublicKey
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class JWTVerifier:
|
|
32
|
+
"""Verify JWTs, then map verified claims with the synchronous principal hook.
|
|
33
|
+
|
|
34
|
+
Use HMACJWTVerifier or PublicKeyJWTVerifier to select algorithms from trusted
|
|
35
|
+
key material. The mapper may raise ValueError to reject verified claims.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
key: VerificationKey = field(repr=False)
|
|
39
|
+
algorithms: tuple[str, ...]
|
|
40
|
+
issuer: str = ""
|
|
41
|
+
audience: str = ""
|
|
42
|
+
principal: Callable[[Mapping[str, Any]], Principal] = principal_from_claims
|
|
43
|
+
|
|
44
|
+
async def verify(self, credentials: str) -> Principal | None:
|
|
45
|
+
"""Check the signature, required expiry, and configured issuer and audience."""
|
|
46
|
+
try:
|
|
47
|
+
claims = jwt.decode(
|
|
48
|
+
credentials,
|
|
49
|
+
self.key,
|
|
50
|
+
algorithms=self.algorithms,
|
|
51
|
+
issuer=self.issuer or None,
|
|
52
|
+
audience=self.audience or None,
|
|
53
|
+
options={"require": ["exp"], "verify_aud": bool(self.audience)},
|
|
54
|
+
)
|
|
55
|
+
return self.principal(claims)
|
|
56
|
+
except (jwt.InvalidTokenError, ValueError, TypeError, OverflowError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HMACJWTVerifier(JWTVerifier):
|
|
61
|
+
"""Verify HS256 JWTs with a configured pre-shared key."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
pre_shared_key: str,
|
|
66
|
+
*,
|
|
67
|
+
issuer: str = "",
|
|
68
|
+
audience: str = "",
|
|
69
|
+
principal: Callable[[Mapping[str, Any]], Principal] = principal_from_claims,
|
|
70
|
+
) -> None:
|
|
71
|
+
hmac = HMACAlgorithm(HMACAlgorithm.SHA256)
|
|
72
|
+
try:
|
|
73
|
+
key = hmac.prepare_key(pre_shared_key)
|
|
74
|
+
except jwt.InvalidKeyError as error:
|
|
75
|
+
raise ValueError("the JWT pre-shared key must not be an asymmetric key") from error
|
|
76
|
+
if len(key) < 32:
|
|
77
|
+
raise ValueError("the JWT pre-shared key must contain at least 32 bytes")
|
|
78
|
+
super().__init__(key, ("HS256",), issuer, audience, principal)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PublicKeyJWTVerifier(JWTVerifier):
|
|
82
|
+
"""Verify RSA, EC, or EdDSA JWTs with a configured public key."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
public_key: str,
|
|
87
|
+
*,
|
|
88
|
+
issuer: str = "",
|
|
89
|
+
audience: str = "",
|
|
90
|
+
principal: Callable[[Mapping[str, Any]], Principal] = principal_from_claims,
|
|
91
|
+
) -> None:
|
|
92
|
+
material = public_key.strip()
|
|
93
|
+
if not material.startswith("-----BEGIN "):
|
|
94
|
+
material = Path(material).expanduser().read_text(encoding="utf-8")
|
|
95
|
+
try:
|
|
96
|
+
loaded = serialization.load_pem_public_key(material.encode("utf-8"))
|
|
97
|
+
except (ValueError, TypeError, UnsupportedAlgorithm) as error:
|
|
98
|
+
raise ValueError("the JWT public key must be a supported PEM public key") from error
|
|
99
|
+
algorithms: tuple[str, ...]
|
|
100
|
+
if isinstance(loaded, rsa.RSAPublicKey):
|
|
101
|
+
if loaded.key_size < 2048:
|
|
102
|
+
raise ValueError("the JWT RSA public key must contain at least 2048 bits")
|
|
103
|
+
algorithms = ("RS256", "RS384", "RS512", "PS256", "PS384", "PS512")
|
|
104
|
+
elif isinstance(loaded, ec.EllipticCurvePublicKey):
|
|
105
|
+
curves = {"secp256r1": "ES256", "secp384r1": "ES384", "secp521r1": "ES512"}
|
|
106
|
+
algorithm = curves.get(loaded.curve.name)
|
|
107
|
+
if algorithm is None:
|
|
108
|
+
raise ValueError("unsupported JWT elliptic curve")
|
|
109
|
+
algorithms = (algorithm,)
|
|
110
|
+
elif isinstance(loaded, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
|
|
111
|
+
algorithms = ("EdDSA",)
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError("unsupported JWT public key type")
|
|
114
|
+
super().__init__(loaded, algorithms, issuer, audience, principal)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["HMACJWTVerifier", "JWTVerifier", "PublicKeyJWTVerifier"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP and stdio clients with shared request handling."""
|
|
@@ -7,9 +7,9 @@ from abc import ABC, abstractmethod
|
|
|
7
7
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from .adapter import Adapter
|
|
11
|
-
from .core import ClientProfile, InputRequest, Operation
|
|
12
|
-
from .models import (
|
|
10
|
+
from aiohttp_tiny_mcp.protocol.adapter import Adapter
|
|
11
|
+
from aiohttp_tiny_mcp.protocol.core import ClientProfile, InputRequest, Operation
|
|
12
|
+
from aiohttp_tiny_mcp.protocol.models import (
|
|
13
13
|
CallToolParams,
|
|
14
14
|
CallToolResult,
|
|
15
15
|
CompleteArgument,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""HTTP transport for BaseClient.
|
|
1
|
+
"""HTTP transport for BaseClient. The stdio transport lives in client.stdio."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -9,10 +9,11 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
import aiohttp
|
|
11
11
|
|
|
12
|
-
from .adapter import Adapter
|
|
13
|
-
from .
|
|
14
|
-
from .
|
|
15
|
-
|
|
12
|
+
from aiohttp_tiny_mcp.protocol.adapter import Adapter
|
|
13
|
+
from aiohttp_tiny_mcp.protocol.models import Implementation
|
|
14
|
+
from aiohttp_tiny_mcp.transport.sse import read_sse
|
|
15
|
+
|
|
16
|
+
from .base import BaseClient, Elicitor
|
|
16
17
|
|
|
17
18
|
ANSWER_METHOD = "elicitation/create"
|
|
18
19
|
|
|
@@ -8,9 +8,10 @@ from collections.abc import AsyncIterator, Callable, Mapping
|
|
|
8
8
|
from contextlib import asynccontextmanager
|
|
9
9
|
from typing import Any, Protocol, runtime_checkable
|
|
10
10
|
|
|
11
|
-
from .adapter import Adapter
|
|
12
|
-
from .
|
|
13
|
-
|
|
11
|
+
from aiohttp_tiny_mcp.protocol.adapter import Adapter
|
|
12
|
+
from aiohttp_tiny_mcp.protocol.models import Implementation
|
|
13
|
+
|
|
14
|
+
from .base import BaseClient, ClientError, Elicitor
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@runtime_checkable
|
|
@@ -17,6 +17,7 @@ FILES = {
|
|
|
17
17
|
"": ("index.html", "text/html"),
|
|
18
18
|
"console.js": ("console.js", "application/javascript"),
|
|
19
19
|
"console.css": ("console.css", "text/css"),
|
|
20
|
+
"oauth-callback": ("oauth-callback.html", "text/html"),
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
|
|
@@ -31,12 +32,14 @@ class Console:
|
|
|
31
32
|
description: str = "",
|
|
32
33
|
template: str | Path | None = None,
|
|
33
34
|
variables: Mapping[str, str] | None = None,
|
|
35
|
+
oauth_client_id: str | None = None,
|
|
34
36
|
) -> None:
|
|
35
37
|
self.endpoint_path = endpoint_path
|
|
36
38
|
self.title = title
|
|
37
39
|
self.description = description
|
|
38
40
|
self.template = Path(template) if template else HERE / "index.html"
|
|
39
41
|
self.variables = dict(variables or {})
|
|
42
|
+
self.oauth_client_id = oauth_client_id
|
|
40
43
|
|
|
41
44
|
def substitutions(self, base: str) -> dict[str, str]:
|
|
42
45
|
return {
|
|
@@ -44,6 +47,7 @@ class Console:
|
|
|
44
47
|
"BASE": base,
|
|
45
48
|
"TITLE": self.title,
|
|
46
49
|
"DESCRIPTION": self.description,
|
|
50
|
+
"OAUTH_CLIENT_ID": self.oauth_client_id or "",
|
|
47
51
|
**self.variables,
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -80,7 +84,7 @@ class Console:
|
|
|
80
84
|
)
|
|
81
85
|
|
|
82
86
|
def routes(self, path: str = "/console") -> list[web.RouteDef]:
|
|
83
|
-
"""The page,
|
|
87
|
+
"""The page, its assets, and the OAuth callback page."""
|
|
84
88
|
base = path.rstrip("/")
|
|
85
89
|
log.debug("console at %s, for the endpoint at %s", base, self.endpoint_path)
|
|
86
90
|
return [
|
|
@@ -89,6 +93,22 @@ class Console:
|
|
|
89
93
|
web.get(f"{base}/{{file}}", self.handle),
|
|
90
94
|
]
|
|
91
95
|
|
|
96
|
+
@staticmethod
|
|
97
|
+
def is_public(request: web.Request) -> bool:
|
|
98
|
+
"""Identify a mounted console asset for application authentication middleware.
|
|
99
|
+
|
|
100
|
+
Check the resolved handler, method, and file allowlist. No path prefix
|
|
101
|
+
is trusted, and an unmounted console grants no exemptions.
|
|
102
|
+
"""
|
|
103
|
+
handler = request.match_info.handler
|
|
104
|
+
console = getattr(handler, "__self__", None)
|
|
105
|
+
return (
|
|
106
|
+
request.method in {"GET", "HEAD"}
|
|
107
|
+
and isinstance(console, Console)
|
|
108
|
+
and handler == console.handle
|
|
109
|
+
and request.match_info.get("file", "") in FILES
|
|
110
|
+
)
|
|
111
|
+
|
|
92
112
|
def setup(self, app: web.Application, path: str = "/console") -> web.Application:
|
|
93
113
|
log.debug("adding the console routes to %r", app)
|
|
94
114
|
app.add_routes(self.routes(path))
|