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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,8 @@
1
+ """Run `azathoth-mcp` as `python -m azathoth_mcp`."""
2
+
3
+ import sys
4
+
5
+ from azathoth_mcp.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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
+ ]