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.
Files changed (59) hide show
  1. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/PKG-INFO +7 -5
  2. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/README.md +4 -4
  3. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/pyproject.toml +7 -2
  4. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/pyproject.toml.orig +8 -4
  5. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/__init__.py +30 -17
  6. 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
  7. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/auth/jwt.py +117 -0
  8. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/client/__init__.py +1 -0
  9. 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
  10. 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
  11. 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
  12. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/__init__.py +21 -1
  13. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/console.css +9 -2
  14. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/console.js +420 -18
  15. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/console/index.html +36 -1
  16. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/console/oauth-callback.html +14 -0
  17. 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
  18. {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
  19. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/__init__.py +19 -0
  20. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/github.py +50 -0
  21. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/provider.py +136 -0
  22. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/server.py +547 -0
  23. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/oauth/tokens.py +220 -0
  24. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/protocol/__init__.py +1 -0
  25. {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
  26. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/selection.py +2 -2
  27. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2024_11_05.py +3 -3
  28. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2025_03_26.py +3 -3
  29. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2025_11_25.py +5 -5
  30. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2026_07_28.py +5 -5
  31. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/server/__init__.py +1 -0
  32. {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
  33. {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
  34. 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
  35. {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
  36. {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
  37. {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
  38. 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
  39. {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
  40. {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
  41. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/storage/__init__.py +1 -0
  42. {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
  43. {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
  44. {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
  45. {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
  46. {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
  47. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/testing.py +9 -9
  48. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/transport/__init__.py +1 -0
  49. aiohttp_tiny_mcp-0.5.0/src/aiohttp_tiny_mcp/transport/metadata.py +44 -0
  50. aiohttp_tiny_mcp-0.4.0/src/aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  51. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/LICENSE +0 -0
  52. {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
  53. {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
  54. {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
  55. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/protocol/v2025_06_18.py +0 -0
  56. {aiohttp_tiny_mcp-0.4.0 → aiohttp_tiny_mcp-0.5.0}/src/aiohttp_tiny_mcp/py.typed +0 -0
  57. {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
  58. {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
  59. {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.4.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. Three files from
177
- this package, no build step and no second process.
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, custom subclasses, multiple policies, and permissions.
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. Three files from
135
- this package, no build step and no second process.
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, custom subclasses, multiple policies, and permissions.
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.4.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.4.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 Authentication, Authorization, BasicAuth, Principal, StaticBasicAuth, Unauthorized
6
- from .client import Client
7
- from .client_base import ClientError, Elicitor
8
- from .core import (
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 .endpoint import Endpoint
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 .registry import Registry
40
- from .request_state import RequestStates
41
- from .sessions import MemorySessionStore, SessionRecord, SessionStore
42
- from .sse import SSEResponse
43
- from .stdio import run_stdio, serve_stdio
44
- from .stdio_client import StdioClient
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 one configured username and password.
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 one configured account. Use HTTPS in production."""
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
- username: str,
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 ":" in username or any(
188
- ord(char) < 32 or ord(char) == 127 for char in username + password
189
- ):
190
- raise ValueError("Basic credentials cannot contain controls or a colon in the username")
191
- if not username or not password:
192
- raise ValueError("username and password must be non-empty")
193
- self._username = username.encode("utf-8")
194
- self._password = password.encode("utf-8")
195
- self._principal = Principal(subject=username, scopes=frozenset(scopes))
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
- matched = compare_digest(username.encode("utf-8"), self._username)
199
- matched &= compare_digest(password.encode("utf-8"), self._password)
200
- return self._principal if matched else None
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. See stdio_client.py for the stdio transport."""
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 .client_base import BaseClient, Elicitor
14
- from .models import Implementation
15
- from .sse import read_sse
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 .client_base import BaseClient, ClientError, Elicitor
13
- from .models import Implementation
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, either way it is addressed, and its two files."""
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))