azathoth-mcp 1.2.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.
- azathoth_mcp-1.2.0/LICENSE +21 -0
- azathoth_mcp-1.2.0/PKG-INFO +54 -0
- azathoth_mcp-1.2.0/README.md +28 -0
- azathoth_mcp-1.2.0/pyproject.toml +53 -0
- azathoth_mcp-1.2.0/setup.cfg +4 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/__init__.py +8 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/__main__.py +8 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/access.py +147 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/authorization.py +342 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/catalog.py +270 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/cli.py +490 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/evidence.py +175 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/exposure.py +387 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/progress.py +85 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/py.typed +0 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/runtimes.py +66 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/server.py +212 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/service.py +377 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp/web.py +201 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp.egg-info/PKG-INFO +54 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp.egg-info/SOURCES.txt +23 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp.egg-info/dependency_links.txt +1 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp.egg-info/entry_points.txt +2 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp.egg-info/requires.txt +3 -0
- azathoth_mcp-1.2.0/src/azathoth_mcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Eldritch, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: azathoth-mcp
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Serve promoted Azathoth workflows as Model Context Protocol tools.
|
|
5
|
+
Author: Eris Discordia Montano
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/EldritchInc/azathoth
|
|
8
|
+
Project-URL: Repository, https://github.com/EldritchInc/azathoth
|
|
9
|
+
Project-URL: Issues, https://github.com/EldritchInc/azathoth/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/EldritchInc/azathoth/blob/main/docs/mcp.md
|
|
11
|
+
Keywords: artificial-intelligence,llm,mcp,model-context-protocol,workflow-optimization
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: azathoth-ai==1.2.*
|
|
23
|
+
Requires-Dist: mcp<3,>=2.3
|
|
24
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Azathoth MCP
|
|
28
|
+
|
|
29
|
+
Azathoth MCP serves promoted
|
|
30
|
+
[Azathoth](https://github.com/EldritchInc/azathoth) workflows as
|
|
31
|
+
[Model Context Protocol](https://modelcontextprotocol.io) tools, so an AI
|
|
32
|
+
client such as Claude Desktop, an IDE or your own agent can call them.
|
|
33
|
+
|
|
34
|
+
Only the workflows an operator lists are offered, each as one tool with the
|
|
35
|
+
name and description the operator wrote. Every call runs the workflow's
|
|
36
|
+
active production revision as a recorded, idempotent invocation, and returns
|
|
37
|
+
the invocation's ID with its result, so what an agent did can be traced to
|
|
38
|
+
its runs and evidence.
|
|
39
|
+
|
|
40
|
+
It runs over stdio for a local client, or over Streamable HTTP for a team,
|
|
41
|
+
as an OAuth 2.1 resource server in front of your organization's identity
|
|
42
|
+
provider.
|
|
43
|
+
|
|
44
|
+
[Serving Workflows Over MCP](https://github.com/EldritchInc/azathoth/blob/main/docs/mcp.md)
|
|
45
|
+
shows how to expose a workflow, connect Claude Desktop, and serve a team
|
|
46
|
+
behind your identity provider.
|
|
47
|
+
|
|
48
|
+
Azathoth MCP is released with Azathoth, at the same version, and requires
|
|
49
|
+
the same minor release of `azathoth-ai`. The design is recorded in
|
|
50
|
+
[ADR 0111](https://github.com/EldritchInc/azathoth/blob/main/docs/adrs/0111-serve-promoted-workflows-over-mcp.md).
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT. See [LICENSE](https://github.com/EldritchInc/azathoth/blob/main/packages/azathoth-mcp/LICENSE).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Azathoth MCP
|
|
2
|
+
|
|
3
|
+
Azathoth MCP serves promoted
|
|
4
|
+
[Azathoth](https://github.com/EldritchInc/azathoth) workflows as
|
|
5
|
+
[Model Context Protocol](https://modelcontextprotocol.io) tools, so an AI
|
|
6
|
+
client such as Claude Desktop, an IDE or your own agent can call them.
|
|
7
|
+
|
|
8
|
+
Only the workflows an operator lists are offered, each as one tool with the
|
|
9
|
+
name and description the operator wrote. Every call runs the workflow's
|
|
10
|
+
active production revision as a recorded, idempotent invocation, and returns
|
|
11
|
+
the invocation's ID with its result, so what an agent did can be traced to
|
|
12
|
+
its runs and evidence.
|
|
13
|
+
|
|
14
|
+
It runs over stdio for a local client, or over Streamable HTTP for a team,
|
|
15
|
+
as an OAuth 2.1 resource server in front of your organization's identity
|
|
16
|
+
provider.
|
|
17
|
+
|
|
18
|
+
[Serving Workflows Over MCP](https://github.com/EldritchInc/azathoth/blob/main/docs/mcp.md)
|
|
19
|
+
shows how to expose a workflow, connect Claude Desktop, and serve a team
|
|
20
|
+
behind your identity provider.
|
|
21
|
+
|
|
22
|
+
Azathoth MCP is released with Azathoth, at the same version, and requires
|
|
23
|
+
the same minor release of `azathoth-ai`. The design is recorded in
|
|
24
|
+
[ADR 0111](https://github.com/EldritchInc/azathoth/blob/main/docs/adrs/0111-serve-promoted-workflows-over-mcp.md).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT. See [LICENSE](https://github.com/EldritchInc/azathoth/blob/main/packages/azathoth-mcp/LICENSE).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "azathoth-mcp"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Serve promoted Azathoth workflows as Model Context Protocol tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Eris Discordia Montano" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"artificial-intelligence",
|
|
17
|
+
"llm",
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"workflow-optimization"
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 5 - Production/Stable",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Typing :: Typed"
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"azathoth-ai==1.2.*",
|
|
33
|
+
"mcp>=2.3,<3",
|
|
34
|
+
"pyjwt[crypto]>=2.10,<3",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
azathoth-mcp = "azathoth_mcp.cli:main"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/EldritchInc/azathoth"
|
|
42
|
+
Repository = "https://github.com/EldritchInc/azathoth"
|
|
43
|
+
Issues = "https://github.com/EldritchInc/azathoth/issues"
|
|
44
|
+
Documentation = "https://github.com/EldritchInc/azathoth/blob/main/docs/mcp.md"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
package-dir = {"" = "src"}
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.package-data]
|
|
53
|
+
azathoth_mcp = ["py.typed"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Azathoth MCP: serve promoted Azathoth workflows as Model Context Protocol tools (ADR 0111).
|
|
2
|
+
|
|
3
|
+
This distribution is released in lockstep with `azathoth-ai`: it has the same
|
|
4
|
+
version and requires the same minor release, so it may use Azathoth's Python
|
|
5
|
+
API as it stands in that release.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.2.0"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Who is calling, and what they may call (ADR 0111).
|
|
2
|
+
|
|
3
|
+
A caller over stdio started the server with its own credentials, and may
|
|
4
|
+
call everything the server offers. A caller over HTTP holds a token, and may
|
|
5
|
+
call only what the token's scopes allow:
|
|
6
|
+
|
|
7
|
+
- `azathoth:invoke:<tool>` for each exposed workflow's tool;
|
|
8
|
+
- `azathoth:read` for `get_invocation`, which then returns only the
|
|
9
|
+
invocations recorded with the caller's own subject;
|
|
10
|
+
- `azathoth:read:all` to read every invocation of an exposed workflow.
|
|
11
|
+
|
|
12
|
+
The caller is recorded with every invocation, under `mcp` in its caller
|
|
13
|
+
metadata, so `invocation show` names who made it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
19
|
+
|
|
20
|
+
SCOPE_PREFIX = "azathoth:"
|
|
21
|
+
|
|
22
|
+
READ_SCOPE = "azathoth:read"
|
|
23
|
+
"""Read the invocations recorded with the caller's own subject."""
|
|
24
|
+
|
|
25
|
+
READ_ALL_SCOPE = "azathoth:read:all"
|
|
26
|
+
"""Read every invocation of an exposed workflow, whoever made it."""
|
|
27
|
+
|
|
28
|
+
CALLER_METADATA_KEY = "mcp"
|
|
29
|
+
"""Where an invocation's caller metadata records the MCP caller."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def invoke_scope(
|
|
33
|
+
tool: str,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Return the scope that allows calling one exposed workflow's tool."""
|
|
36
|
+
|
|
37
|
+
return f"azathoth:invoke:{tool}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Transport(StrEnum):
|
|
41
|
+
"""How a caller reaches the server."""
|
|
42
|
+
|
|
43
|
+
STDIO = "stdio"
|
|
44
|
+
HTTP = "http"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ToolAccessError(PermissionError):
|
|
48
|
+
"""Raised when a caller's token does not allow a call."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class McpCaller(BaseModel):
|
|
52
|
+
"""The client one call comes from.
|
|
53
|
+
|
|
54
|
+
`scopes` is what the caller's token grants, or `None` for a caller the
|
|
55
|
+
server does not authorize, over stdio or a loopback-only HTTP server.
|
|
56
|
+
`subject` and `client_id` come from the token, when there is one.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(frozen=True)
|
|
60
|
+
|
|
61
|
+
transport: Transport
|
|
62
|
+
subject: str | None = Field(default=None, min_length=1)
|
|
63
|
+
client_id: str | None = Field(default=None, min_length=1)
|
|
64
|
+
scopes: frozenset[str] | None = None
|
|
65
|
+
|
|
66
|
+
def require(
|
|
67
|
+
self,
|
|
68
|
+
scope: str,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Refuse a call this caller's token does not allow.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ToolAccessError: The caller is authorized, and its token does not
|
|
74
|
+
carry the scope.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
if self.scopes is not None and scope not in self.scopes:
|
|
78
|
+
raise ToolAccessError(f'This call needs the scope "{scope}".')
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def reads_every_invocation(
|
|
82
|
+
self,
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""Report whether this caller may read invocations other callers made."""
|
|
85
|
+
|
|
86
|
+
return self.scopes is None or READ_ALL_SCOPE in self.scopes
|
|
87
|
+
|
|
88
|
+
def may_read(
|
|
89
|
+
self,
|
|
90
|
+
caller_metadata: dict[str, JsonValue],
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""Report whether this caller may read an invocation with this caller metadata.
|
|
93
|
+
|
|
94
|
+
A caller that reads every invocation may read any. Another may read
|
|
95
|
+
only one recorded over MCP with its own subject.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
if self.reads_every_invocation:
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
recorded = caller_metadata.get(CALLER_METADATA_KEY)
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
self.subject is not None
|
|
105
|
+
and isinstance(recorded, dict)
|
|
106
|
+
and recorded.get("subject") == self.subject
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def caller_metadata(
|
|
110
|
+
self,
|
|
111
|
+
tool: str,
|
|
112
|
+
) -> dict[str, JsonValue]:
|
|
113
|
+
"""Return the caller metadata one invocation of a tool records.
|
|
114
|
+
|
|
115
|
+
It holds the transport and the tool, and the token's subject and
|
|
116
|
+
client ID when there is a token. It never holds the token itself.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
recorded: dict[str, JsonValue] = {
|
|
120
|
+
"transport": self.transport.value,
|
|
121
|
+
"tool": tool,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if self.subject is not None:
|
|
125
|
+
recorded["subject"] = self.subject
|
|
126
|
+
|
|
127
|
+
if self.client_id is not None:
|
|
128
|
+
recorded["client_id"] = self.client_id
|
|
129
|
+
|
|
130
|
+
return {CALLER_METADATA_KEY: recorded}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
STDIO_CALLER = McpCaller(transport=Transport.STDIO)
|
|
134
|
+
"""The one caller of a server over stdio."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = [
|
|
138
|
+
"CALLER_METADATA_KEY",
|
|
139
|
+
"READ_ALL_SCOPE",
|
|
140
|
+
"READ_SCOPE",
|
|
141
|
+
"SCOPE_PREFIX",
|
|
142
|
+
"STDIO_CALLER",
|
|
143
|
+
"McpCaller",
|
|
144
|
+
"ToolAccessError",
|
|
145
|
+
"Transport",
|
|
146
|
+
"invoke_scope",
|
|
147
|
+
]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Authorizing HTTP callers: an OAuth 2.1 resource server (ADR 0111).
|
|
2
|
+
|
|
3
|
+
The server issues no tokens and signs no one in; the organization's
|
|
4
|
+
identity provider does. Every request carries a bearer token, a signed JWT,
|
|
5
|
+
which the server accepts only when all of these hold:
|
|
6
|
+
|
|
7
|
+
- it is signed, by an algorithm on the allow-list, with one of the
|
|
8
|
+
provider's published keys;
|
|
9
|
+
- its issuer is the provider's;
|
|
10
|
+
- its audience holds this server's own resource URL (RFC 8707), so a token
|
|
11
|
+
issued for another service is refused;
|
|
12
|
+
- it has an expiry, and has not expired; with a maximum lifetime set, it
|
|
13
|
+
also says when it was issued, and lives no longer than that;
|
|
14
|
+
- it names its subject.
|
|
15
|
+
|
|
16
|
+
The token's scopes, from `scope` or `scp`, decide what the caller may call
|
|
17
|
+
(`azathoth_mcp.access`). A token is never logged, never passed on, and never
|
|
18
|
+
kept beyond its request.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
import jwt
|
|
29
|
+
from mcp.server.auth.provider import AccessToken
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
31
|
+
|
|
32
|
+
from azathoth_mcp.access import McpCaller, Transport
|
|
33
|
+
|
|
34
|
+
DEFAULT_ALGORITHMS = ("RS256", "ES256")
|
|
35
|
+
"""The signing algorithms accepted unless the operator names others."""
|
|
36
|
+
|
|
37
|
+
ASYMMETRIC_ALGORITHMS = frozenset(
|
|
38
|
+
{"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"}
|
|
39
|
+
)
|
|
40
|
+
"""The algorithms an operator may allow: public-key ones only, never `none` or HMAC."""
|
|
41
|
+
|
|
42
|
+
DEFAULT_LEEWAY_SECONDS = 30
|
|
43
|
+
"""How far apart the server's clock and the provider's may be."""
|
|
44
|
+
|
|
45
|
+
DISCOVERY_PATHS = (
|
|
46
|
+
"/.well-known/openid-configuration",
|
|
47
|
+
"/.well-known/oauth-authorization-server",
|
|
48
|
+
)
|
|
49
|
+
"""Where an issuer's metadata names its keys, OpenID Connect's first (RFC 8414)."""
|
|
50
|
+
|
|
51
|
+
HTTPS_URL = re.compile(r"https://[^\s/?#]+(?:/[^\s?#]*)?")
|
|
52
|
+
|
|
53
|
+
LOOPBACK_HTTP_URL = re.compile(r"http://(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:/[^\s?#]*)?")
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthorizationConfigurationError(ValueError):
|
|
59
|
+
"""Raised when the server cannot be set up to authorize its callers."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AuthorizationSettings(BaseModel):
|
|
63
|
+
"""How one server authorizes its HTTP callers.
|
|
64
|
+
|
|
65
|
+
`issuer` is the identity provider's issuer, exactly as its tokens carry
|
|
66
|
+
it. `resource` is this server's own URL, the one clients connect to and
|
|
67
|
+
tokens must name as their audience. Both are HTTPS, or HTTP on a loopback
|
|
68
|
+
address for testing.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(frozen=True)
|
|
72
|
+
|
|
73
|
+
issuer: str
|
|
74
|
+
resource: str
|
|
75
|
+
jwks_url: str | None = None
|
|
76
|
+
algorithms: tuple[str, ...] = DEFAULT_ALGORITHMS
|
|
77
|
+
max_lifetime_seconds: int | None = Field(default=None, gt=0)
|
|
78
|
+
leeway_seconds: int = Field(default=DEFAULT_LEEWAY_SECONDS, ge=0)
|
|
79
|
+
|
|
80
|
+
@field_validator("issuer", "resource", "jwks_url")
|
|
81
|
+
@classmethod
|
|
82
|
+
def require_secure_url(
|
|
83
|
+
cls,
|
|
84
|
+
url: str | None,
|
|
85
|
+
) -> str | None:
|
|
86
|
+
"""Refuse a URL that is not HTTPS, unless it is HTTP on a loopback address."""
|
|
87
|
+
|
|
88
|
+
if url is None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
if HTTPS_URL.fullmatch(url) is None and LOOPBACK_HTTP_URL.fullmatch(url) is None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"{url} must be an HTTPS URL, or an HTTP URL on a loopback address, "
|
|
94
|
+
"with no query or fragment."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return url
|
|
98
|
+
|
|
99
|
+
@field_validator("algorithms")
|
|
100
|
+
@classmethod
|
|
101
|
+
def require_public_key_algorithms(
|
|
102
|
+
cls,
|
|
103
|
+
algorithms: tuple[str, ...],
|
|
104
|
+
) -> tuple[str, ...]:
|
|
105
|
+
"""Refuse an empty allow-list, and any algorithm that is not public-key."""
|
|
106
|
+
|
|
107
|
+
if not algorithms:
|
|
108
|
+
raise ValueError("At least one signing algorithm must be allowed.")
|
|
109
|
+
|
|
110
|
+
refused = sorted(set(algorithms) - ASYMMETRIC_ALGORITHMS)
|
|
111
|
+
|
|
112
|
+
if refused:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"Only public-key signing algorithms may be allowed; not " + ", ".join(refused) + "."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return algorithms
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SigningKeys(Protocol):
|
|
121
|
+
"""Where a verifier finds the key that signed one token."""
|
|
122
|
+
|
|
123
|
+
def key_for(
|
|
124
|
+
self,
|
|
125
|
+
token: str,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Return the public key that signed one token.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
jwt.PyJWTError: No published key signed it, or the keys cannot be read.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PublishedKeys:
|
|
137
|
+
"""An identity provider's published keys (its JWKS), cached and refreshed as keys rotate."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
jwks_url: str,
|
|
142
|
+
) -> None:
|
|
143
|
+
self._client = jwt.PyJWKClient(jwks_url, cache_keys=True, lifespan=300, timeout=10)
|
|
144
|
+
|
|
145
|
+
def key_for(
|
|
146
|
+
self,
|
|
147
|
+
token: str,
|
|
148
|
+
) -> Any:
|
|
149
|
+
"""Return the published key named by one token's `kid`."""
|
|
150
|
+
|
|
151
|
+
return self._client.get_signing_key_from_jwt(token).key
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def discover_jwks_url(
|
|
155
|
+
issuer: str,
|
|
156
|
+
*,
|
|
157
|
+
client: httpx.Client | None = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Return the URL of an issuer's published keys, from its metadata.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
AuthorizationConfigurationError: Neither metadata document can be
|
|
163
|
+
read, or neither names its keys.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
owned = client is None
|
|
167
|
+
http = client if client is not None else httpx.Client(timeout=10)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
for path in DISCOVERY_PATHS:
|
|
171
|
+
try:
|
|
172
|
+
response = http.get(issuer.rstrip("/") + path)
|
|
173
|
+
except httpx.HTTPError:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if response.status_code != 200:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
metadata = response.json()
|
|
181
|
+
except ValueError:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
jwks_url = metadata.get("jwks_uri") if isinstance(metadata, dict) else None
|
|
185
|
+
|
|
186
|
+
if isinstance(jwks_url, str) and jwks_url:
|
|
187
|
+
return jwks_url
|
|
188
|
+
finally:
|
|
189
|
+
if owned:
|
|
190
|
+
http.close()
|
|
191
|
+
|
|
192
|
+
raise AuthorizationConfigurationError(
|
|
193
|
+
f"Cannot find the signing keys of {issuer}: neither its OpenID Connect nor its "
|
|
194
|
+
"OAuth metadata names a jwks_uri. Name them with --jwks-url."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def token_scopes(
|
|
199
|
+
claims: Mapping[str, Any],
|
|
200
|
+
) -> tuple[str, ...]:
|
|
201
|
+
"""Return a token's scopes, from `scope` or, as some providers write it, `scp`.
|
|
202
|
+
|
|
203
|
+
Either may be one space-separated string or a list of strings.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
for name in ("scope", "scp"):
|
|
207
|
+
value = claims.get(name)
|
|
208
|
+
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
return tuple(value.split())
|
|
211
|
+
|
|
212
|
+
if isinstance(value, list) and all(isinstance(scope, str) for scope in value):
|
|
213
|
+
return tuple(value)
|
|
214
|
+
|
|
215
|
+
return ()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def token_client_id(
|
|
219
|
+
claims: Mapping[str, Any],
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Return the client a token was issued to, or an empty string if it names none."""
|
|
222
|
+
|
|
223
|
+
for name in ("client_id", "azp", "appid"):
|
|
224
|
+
value = claims.get(name)
|
|
225
|
+
|
|
226
|
+
if isinstance(value, str) and value:
|
|
227
|
+
return value
|
|
228
|
+
|
|
229
|
+
return ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class JwtTokenVerifier:
|
|
233
|
+
"""Verify bearer tokens as signed JWTs, as the SDK's bearer middleware asks."""
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
settings: AuthorizationSettings,
|
|
238
|
+
keys: SigningKeys,
|
|
239
|
+
) -> None:
|
|
240
|
+
self._settings = settings
|
|
241
|
+
self._keys = keys
|
|
242
|
+
|
|
243
|
+
async def verify_token(
|
|
244
|
+
self,
|
|
245
|
+
token: str,
|
|
246
|
+
) -> AccessToken | None:
|
|
247
|
+
"""Return the access a valid token grants, or `None` for any other.
|
|
248
|
+
|
|
249
|
+
Why a token was refused is logged by kind, never with the token.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
claims = await asyncio.to_thread(self._claims, token)
|
|
254
|
+
except jwt.PyJWTError as exc:
|
|
255
|
+
logger.info("A bearer token was refused: %s.", type(exc).__name__)
|
|
256
|
+
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
return AccessToken(
|
|
260
|
+
token=token,
|
|
261
|
+
client_id=token_client_id(claims),
|
|
262
|
+
scopes=list(token_scopes(claims)),
|
|
263
|
+
expires_at=int(claims["exp"]),
|
|
264
|
+
resource=self._settings.resource,
|
|
265
|
+
subject=str(claims["sub"]),
|
|
266
|
+
claims={"iss": claims["iss"]},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _claims(
|
|
270
|
+
self,
|
|
271
|
+
token: str,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Return one token's verified claims.
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
jwt.PyJWTError: The token is not one this server accepts.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
settings = self._settings
|
|
280
|
+
required = ["exp", "iss", "aud", "sub"]
|
|
281
|
+
|
|
282
|
+
if settings.max_lifetime_seconds is not None:
|
|
283
|
+
required.append("iat")
|
|
284
|
+
|
|
285
|
+
claims: dict[str, Any] = jwt.decode(
|
|
286
|
+
token,
|
|
287
|
+
self._keys.key_for(token),
|
|
288
|
+
algorithms=list(settings.algorithms),
|
|
289
|
+
audience=settings.resource,
|
|
290
|
+
issuer=settings.issuer,
|
|
291
|
+
leeway=settings.leeway_seconds,
|
|
292
|
+
options={"require": required},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if settings.max_lifetime_seconds is not None:
|
|
296
|
+
lifetime = int(claims["exp"]) - int(claims["iat"])
|
|
297
|
+
|
|
298
|
+
if lifetime > settings.max_lifetime_seconds:
|
|
299
|
+
raise jwt.InvalidTokenError("The token lives longer than this server allows.")
|
|
300
|
+
|
|
301
|
+
if not isinstance(claims["sub"], str) or not claims["sub"]:
|
|
302
|
+
raise jwt.MissingRequiredClaimError("sub")
|
|
303
|
+
|
|
304
|
+
return claims
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def http_caller(
|
|
308
|
+
access: AccessToken | None,
|
|
309
|
+
) -> McpCaller:
|
|
310
|
+
"""Return the caller one HTTP request comes from.
|
|
311
|
+
|
|
312
|
+
A request without a token reaches the server only when it runs without
|
|
313
|
+
authorization, on a loopback address; that caller may call everything,
|
|
314
|
+
as the local caller over stdio may.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
if access is None:
|
|
318
|
+
return McpCaller(transport=Transport.HTTP)
|
|
319
|
+
|
|
320
|
+
return McpCaller(
|
|
321
|
+
transport=Transport.HTTP,
|
|
322
|
+
subject=access.subject or None,
|
|
323
|
+
client_id=access.client_id or None,
|
|
324
|
+
scopes=frozenset(access.scopes),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
__all__ = [
|
|
329
|
+
"ASYMMETRIC_ALGORITHMS",
|
|
330
|
+
"DEFAULT_ALGORITHMS",
|
|
331
|
+
"DEFAULT_LEEWAY_SECONDS",
|
|
332
|
+
"DISCOVERY_PATHS",
|
|
333
|
+
"AuthorizationConfigurationError",
|
|
334
|
+
"AuthorizationSettings",
|
|
335
|
+
"JwtTokenVerifier",
|
|
336
|
+
"PublishedKeys",
|
|
337
|
+
"SigningKeys",
|
|
338
|
+
"discover_jwks_url",
|
|
339
|
+
"http_caller",
|
|
340
|
+
"token_client_id",
|
|
341
|
+
"token_scopes",
|
|
342
|
+
]
|