mirror of
https://github.com/aljazceru/mcp-python-sdk.git
synced 2025-12-18 14:34:27 +01:00
Add OAuth authentication client for HTTPX (#751)
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
54
README.md
54
README.md
@@ -796,6 +796,60 @@ async def main():
|
||||
tool_result = await session.call_tool("echo", {"message": "hello"})
|
||||
```
|
||||
|
||||
### OAuth Authentication for Clients
|
||||
|
||||
The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers:
|
||||
|
||||
```python
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
|
||||
class CustomTokenStorage(TokenStorage):
|
||||
"""Simple in-memory token storage implementation."""
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
pass
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
pass
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
pass
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def main():
|
||||
# Set up OAuth authentication
|
||||
oauth_auth = OAuthClientProvider(
|
||||
server_url="https://api.example.com",
|
||||
client_metadata=OAuthClientMetadata(
|
||||
client_name="My Client",
|
||||
redirect_uris=["http://localhost:3000/callback"],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
),
|
||||
storage=CustomTokenStorage(),
|
||||
redirect_handler=lambda url: print(f"Visit: {url}"),
|
||||
callback_handler=lambda: ("auth_code", None),
|
||||
)
|
||||
|
||||
# Use with streamable HTTP client
|
||||
async with streamablehttp_client(
|
||||
"https://api.example.com/mcp", auth=oauth_auth
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
# Authenticated session ready
|
||||
```
|
||||
|
||||
For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/).
|
||||
|
||||
|
||||
### MCP Primitives
|
||||
|
||||
The MCP protocol defines three core primitives that servers can implement:
|
||||
|
||||
70
examples/clients/simple-auth-client/README.md
Normal file
70
examples/clients/simple-auth-client/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Simple Auth Client Example
|
||||
|
||||
A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP transport.
|
||||
|
||||
## Features
|
||||
|
||||
- OAuth 2.0 authentication with PKCE
|
||||
- Streamable HTTP transport
|
||||
- Interactive command-line interface
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd examples/clients/simple-auth-client
|
||||
uv sync --reinstall
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Start an MCP server with OAuth support
|
||||
|
||||
```bash
|
||||
# Example with mcp-simple-auth
|
||||
cd path/to/mcp-simple-auth
|
||||
uv run mcp-simple-auth --transport streamable-http --port 3001
|
||||
```
|
||||
|
||||
### 2. Run the client
|
||||
|
||||
```bash
|
||||
uv run mcp-simple-auth-client
|
||||
|
||||
# Or with custom server URL
|
||||
MCP_SERVER_URL=http://localhost:3001 uv run mcp-simple-auth-client
|
||||
```
|
||||
|
||||
### 3. Complete OAuth flow
|
||||
|
||||
The client will open your browser for authentication. After completing OAuth, you can use commands:
|
||||
|
||||
- `list` - List available tools
|
||||
- `call <tool_name> [args]` - Call a tool with optional JSON arguments
|
||||
- `quit` - Exit
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
🔐 Simple MCP Auth Client
|
||||
Connecting to: http://localhost:3001
|
||||
|
||||
Please visit the following URL to authorize the application:
|
||||
http://localhost:3001/authorize?response_type=code&client_id=...
|
||||
|
||||
✅ Connected to MCP server at http://localhost:3001
|
||||
|
||||
mcp> list
|
||||
📋 Available tools:
|
||||
1. echo - Echo back the input text
|
||||
|
||||
mcp> call echo {"text": "Hello, world!"}
|
||||
🔧 Tool 'echo' result:
|
||||
Hello, world!
|
||||
|
||||
mcp> quit
|
||||
👋 Goodbye!
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `MCP_SERVER_URL` - Server URL (default: http://localhost:3001)
|
||||
@@ -0,0 +1 @@
|
||||
"""Simple OAuth client for MCP simple-auth server."""
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple MCP client example with OAuth authentication support.
|
||||
|
||||
This client connects to an MCP server using streamable HTTP transport with OAuth.
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import timedelta
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
|
||||
class InMemoryTokenStorage(TokenStorage):
|
||||
"""Simple in-memory token storage implementation."""
|
||||
|
||||
def __init__(self):
|
||||
self._tokens: OAuthToken | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self._tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self._tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self._client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self._client_info = client_info
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Simple HTTP handler to capture OAuth callback."""
|
||||
|
||||
def __init__(self, request, client_address, server, callback_data):
|
||||
"""Initialize with callback data storage."""
|
||||
self.callback_data = callback_data
|
||||
super().__init__(request, client_address, server)
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET request from OAuth redirect."""
|
||||
parsed = urlparse(self.path)
|
||||
query_params = parse_qs(parsed.query)
|
||||
|
||||
if "code" in query_params:
|
||||
self.callback_data["authorization_code"] = query_params["code"][0]
|
||||
self.callback_data["state"] = query_params.get("state", [None])[0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
elif "error" in query_params:
|
||||
self.callback_data["error"] = query_params["error"][0]
|
||||
self.send_response(400)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
f"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>Error: {query_params['error'][0]}</p>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
</body>
|
||||
</html>
|
||||
""".encode()
|
||||
)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default logging."""
|
||||
pass
|
||||
|
||||
|
||||
class CallbackServer:
|
||||
"""Simple server to handle OAuth callbacks."""
|
||||
|
||||
def __init__(self, port=3000):
|
||||
self.port = port
|
||||
self.server = None
|
||||
self.thread = None
|
||||
self.callback_data = {"authorization_code": None, "state": None, "error": None}
|
||||
|
||||
def _create_handler_with_data(self):
|
||||
"""Create a handler class with access to callback data."""
|
||||
callback_data = self.callback_data
|
||||
|
||||
class DataCallbackHandler(CallbackHandler):
|
||||
def __init__(self, request, client_address, server):
|
||||
super().__init__(request, client_address, server, callback_data)
|
||||
|
||||
return DataCallbackHandler
|
||||
|
||||
def start(self):
|
||||
"""Start the callback server in a background thread."""
|
||||
handler_class = self._create_handler_with_data()
|
||||
self.server = HTTPServer(("localhost", self.port), handler_class)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
print(f"🖥️ Started callback server on http://localhost:{self.port}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the callback server."""
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1)
|
||||
|
||||
def wait_for_callback(self, timeout=300):
|
||||
"""Wait for OAuth callback with timeout."""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if self.callback_data["authorization_code"]:
|
||||
return self.callback_data["authorization_code"]
|
||||
elif self.callback_data["error"]:
|
||||
raise Exception(f"OAuth error: {self.callback_data['error']}")
|
||||
time.sleep(0.1)
|
||||
raise Exception("Timeout waiting for OAuth callback")
|
||||
|
||||
def get_state(self):
|
||||
"""Get the received state parameter."""
|
||||
return self.callback_data["state"]
|
||||
|
||||
|
||||
class SimpleAuthClient:
|
||||
"""Simple MCP client with auth support."""
|
||||
|
||||
def __init__(self, server_url: str):
|
||||
self.server_url = server_url
|
||||
self.session: ClientSession | None = None
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the MCP server."""
|
||||
print(f"🔗 Attempting to connect to {self.server_url}...")
|
||||
|
||||
try:
|
||||
# Set up callback server
|
||||
callback_server = CallbackServer(port=3000)
|
||||
callback_server.start()
|
||||
|
||||
async def callback_handler() -> tuple[str, str | None]:
|
||||
"""Wait for OAuth callback and return auth code and state."""
|
||||
print("⏳ Waiting for authorization callback...")
|
||||
try:
|
||||
auth_code = callback_server.wait_for_callback(timeout=300)
|
||||
return auth_code, callback_server.get_state()
|
||||
finally:
|
||||
callback_server.stop()
|
||||
|
||||
client_metadata_dict = {
|
||||
"client_name": "Simple Auth Client",
|
||||
"redirect_uris": ["http://localhost:3000/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
}
|
||||
|
||||
async def _default_redirect_handler(authorization_url: str) -> None:
|
||||
"""Default redirect handler that opens the URL in a browser."""
|
||||
print(f"Opening browser for authorization: {authorization_url}")
|
||||
webbrowser.open(authorization_url)
|
||||
|
||||
# Create OAuth authentication handler using the new interface
|
||||
oauth_auth = OAuthClientProvider(
|
||||
server_url=self.server_url.replace("/mcp", ""),
|
||||
client_metadata=OAuthClientMetadata.model_validate(
|
||||
client_metadata_dict
|
||||
),
|
||||
storage=InMemoryTokenStorage(),
|
||||
redirect_handler=_default_redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
)
|
||||
|
||||
# Create streamable HTTP transport with auth handler
|
||||
stream_context = streamablehttp_client(
|
||||
url=self.server_url,
|
||||
auth=oauth_auth,
|
||||
timeout=timedelta(seconds=60),
|
||||
)
|
||||
|
||||
print(
|
||||
"📡 Opening transport connection (HTTPX handles auth automatically)..."
|
||||
)
|
||||
async with stream_context as (read_stream, write_stream, get_session_id):
|
||||
print("🤝 Initializing MCP session...")
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
self.session = session
|
||||
print("⚡ Starting session initialization...")
|
||||
await session.initialize()
|
||||
print("✨ Session initialization complete!")
|
||||
|
||||
print(f"\n✅ Connected to MCP server at {self.server_url}")
|
||||
session_id = get_session_id()
|
||||
if session_id:
|
||||
print(f"Session ID: {session_id}")
|
||||
|
||||
# Run interactive loop
|
||||
await self.interactive_loop()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
async def list_tools(self):
|
||||
"""List available tools from the server."""
|
||||
if not self.session:
|
||||
print("❌ Not connected to server")
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self.session.list_tools()
|
||||
if hasattr(result, "tools") and result.tools:
|
||||
print("\n📋 Available tools:")
|
||||
for i, tool in enumerate(result.tools, 1):
|
||||
print(f"{i}. {tool.name}")
|
||||
if tool.description:
|
||||
print(f" Description: {tool.description}")
|
||||
print()
|
||||
else:
|
||||
print("No tools available")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to list tools: {e}")
|
||||
|
||||
async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None):
|
||||
"""Call a specific tool."""
|
||||
if not self.session:
|
||||
print("❌ Not connected to server")
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments or {})
|
||||
print(f"\n🔧 Tool '{tool_name}' result:")
|
||||
if hasattr(result, "content"):
|
||||
for content in result.content:
|
||||
if content.type == "text":
|
||||
print(content.text)
|
||||
else:
|
||||
print(content)
|
||||
else:
|
||||
print(result)
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to call tool '{tool_name}': {e}")
|
||||
|
||||
async def interactive_loop(self):
|
||||
"""Run interactive command loop."""
|
||||
print("\n🎯 Interactive MCP Client")
|
||||
print("Commands:")
|
||||
print(" list - List available tools")
|
||||
print(" call <tool_name> [args] - Call a tool")
|
||||
print(" quit - Exit the client")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
command = input("mcp> ").strip()
|
||||
|
||||
if not command:
|
||||
continue
|
||||
|
||||
if command == "quit":
|
||||
break
|
||||
|
||||
elif command == "list":
|
||||
await self.list_tools()
|
||||
|
||||
elif command.startswith("call "):
|
||||
parts = command.split(maxsplit=2)
|
||||
tool_name = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if not tool_name:
|
||||
print("❌ Please specify a tool name")
|
||||
continue
|
||||
|
||||
# Parse arguments (simple JSON-like format)
|
||||
arguments = {}
|
||||
if len(parts) > 2:
|
||||
import json
|
||||
|
||||
try:
|
||||
arguments = json.loads(parts[2])
|
||||
except json.JSONDecodeError:
|
||||
print("❌ Invalid arguments format (expected JSON)")
|
||||
continue
|
||||
|
||||
await self.call_tool(tool_name, arguments)
|
||||
|
||||
else:
|
||||
print(
|
||||
"❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'"
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Goodbye!")
|
||||
break
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
# Default server URL - can be overridden with environment variable
|
||||
# Most MCP streamable HTTP servers use /mcp as the endpoint
|
||||
server_url = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp")
|
||||
|
||||
print("🚀 Simple MCP Auth Client")
|
||||
print(f"Connecting to: {server_url}")
|
||||
|
||||
# Start connection flow - OAuth will be handled automatically
|
||||
client = SimpleAuthClient(server_url)
|
||||
await client.connect()
|
||||
|
||||
|
||||
def cli():
|
||||
"""CLI entry point for uv script."""
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
52
examples/clients/simple-auth-client/pyproject.toml
Normal file
52
examples/clients/simple-auth-client/pyproject.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[project]
|
||||
name = "mcp-simple-auth-client"
|
||||
version = "0.1.0"
|
||||
description = "A simple OAuth client for the MCP simple-auth server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Anthropic" }]
|
||||
keywords = ["mcp", "oauth", "client", "auth"]
|
||||
license = { text = "MIT" }
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = [
|
||||
"click>=8.0.0",
|
||||
"mcp>=1.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-auth-client = "mcp_simple_auth_client.main:cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_auth_client"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_auth_client"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = []
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py310"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]
|
||||
|
||||
[tool.uv.sources]
|
||||
mcp = { path = "../../../" }
|
||||
|
||||
[[tool.uv.index]]
|
||||
url = "https://pypi.org/simple"
|
||||
535
examples/clients/simple-auth-client/uv.lock
generated
Normal file
535
examples/clients/simple-auth-client/uv.lock
generated
Normal file
@@ -0,0 +1,535 @@
|
||||
version = 1
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/62ca20172d4f87d93cf89665fbaedcd560ac48b465bd1d92bfc7ea6b0a41/click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d", size = 235857 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/58/1f37bf81e3c689cc74ffa42102fa8915b59085f54a6e4a80bc6265c0f6bf/click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", size = 102156 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
source = { directory = "../../../" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anyio", specifier = ">=4.5" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
{ name = "pydantic", specifier = ">=2.7.2,<3.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.5.2" },
|
||||
{ name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" },
|
||||
{ name = "sse-starlette", specifier = ">=1.6.1" },
|
||||
{ name = "starlette", specifier = ">=0.27" },
|
||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" },
|
||||
{ name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyright", specifier = ">=1.1.391" },
|
||||
{ name = "pytest", specifier = ">=8.3.4" },
|
||||
{ name = "pytest-examples", specifier = ">=0.0.14" },
|
||||
{ name = "pytest-flakefinder", specifier = ">=1.1.0" },
|
||||
{ name = "pytest-pretty", specifier = ">=1.2.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.6.1" },
|
||||
{ name = "ruff", specifier = ">=0.8.5" },
|
||||
{ name = "trio", specifier = ">=0.26.2" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "mkdocs", specifier = ">=1.6.1" },
|
||||
{ name = "mkdocs-glightbox", specifier = ">=0.4.0" },
|
||||
{ name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" },
|
||||
{ name = "mkdocstrings-python", specifier = ">=1.12.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-simple-auth-client"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mcp" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.0.0" },
|
||||
{ name = "mcp", directory = "../../../" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyright", specifier = ">=1.1.379" },
|
||||
{ name = "pytest", specifier = ">=8.3.3" },
|
||||
{ name = "ruff", specifier = ">=0.6.9" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.400"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "2.3.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 },
|
||||
]
|
||||
501
src/mcp/client/auth.py
Normal file
501
src/mcp/client/auth.py
Normal file
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
OAuth2 Authentication implementation for HTTPX.
|
||||
|
||||
Implements authorization code flow with PKCE and automatic token refresh.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
OAuthToken,
|
||||
)
|
||||
from mcp.types import LATEST_PROTOCOL_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenStorage(Protocol):
|
||||
"""Protocol for token storage implementations."""
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
"""Get stored tokens."""
|
||||
...
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
"""Store tokens."""
|
||||
...
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
"""Get stored client information."""
|
||||
...
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Store client information."""
|
||||
...
|
||||
|
||||
|
||||
class OAuthClientProvider(httpx.Auth):
|
||||
"""
|
||||
Authentication for httpx using anyio.
|
||||
Handles OAuth flow with automatic client registration and token storage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
client_metadata: OAuthClientMetadata,
|
||||
storage: TokenStorage,
|
||||
redirect_handler: Callable[[str], Awaitable[None]],
|
||||
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]],
|
||||
timeout: float = 300.0,
|
||||
):
|
||||
"""
|
||||
Initialize OAuth2 authentication.
|
||||
|
||||
Args:
|
||||
server_url: Base URL of the OAuth server
|
||||
client_metadata: OAuth client metadata
|
||||
storage: Token storage implementation (defaults to in-memory)
|
||||
redirect_handler: Function to handle authorization URL like opening browser
|
||||
callback_handler: Function to wait for callback
|
||||
and return (auth_code, state)
|
||||
timeout: Timeout for OAuth flow in seconds
|
||||
"""
|
||||
self.server_url = server_url
|
||||
self.client_metadata = client_metadata
|
||||
self.storage = storage
|
||||
self.redirect_handler = redirect_handler
|
||||
self.callback_handler = callback_handler
|
||||
self.timeout = timeout
|
||||
|
||||
# Cached authentication state
|
||||
self._current_tokens: OAuthToken | None = None
|
||||
self._metadata: OAuthMetadata | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
self._token_expiry_time: float | None = None
|
||||
|
||||
# PKCE flow parameters
|
||||
self._code_verifier: str | None = None
|
||||
self._code_challenge: str | None = None
|
||||
|
||||
# State parameter for CSRF protection
|
||||
self._auth_state: str | None = None
|
||||
|
||||
# Thread safety lock
|
||||
self._token_lock = anyio.Lock()
|
||||
|
||||
def _generate_code_verifier(self) -> str:
|
||||
"""Generate a cryptographically random code verifier for PKCE."""
|
||||
return "".join(
|
||||
secrets.choice(string.ascii_letters + string.digits + "-._~")
|
||||
for _ in range(128)
|
||||
)
|
||||
|
||||
def _generate_code_challenge(self, code_verifier: str) -> str:
|
||||
"""Generate a code challenge from a code verifier using SHA256."""
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
def _get_authorization_base_url(self, server_url: str) -> str:
|
||||
"""
|
||||
Extract base URL by removing path component.
|
||||
|
||||
Per MCP spec 2.3.2: https://api.example.com/v1/mcp -> https://api.example.com
|
||||
"""
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(server_url)
|
||||
# Remove path component
|
||||
return urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
|
||||
|
||||
async def _discover_oauth_metadata(self, server_url: str) -> OAuthMetadata | None:
|
||||
"""
|
||||
Discover OAuth metadata from server's well-known endpoint.
|
||||
"""
|
||||
# Extract base URL per MCP spec
|
||||
auth_base_url = self._get_authorization_base_url(server_url)
|
||||
url = urljoin(auth_base_url, "/.well-known/oauth-authorization-server")
|
||||
headers = {"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
metadata_json = response.json()
|
||||
logger.debug(f"OAuth metadata discovered: {metadata_json}")
|
||||
return OAuthMetadata.model_validate(metadata_json)
|
||||
except Exception:
|
||||
# Retry without MCP header for CORS compatibility
|
||||
try:
|
||||
response = await client.get(url)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
metadata_json = response.json()
|
||||
logger.debug(
|
||||
f"OAuth metadata discovered (no MCP header): {metadata_json}"
|
||||
)
|
||||
return OAuthMetadata.model_validate(metadata_json)
|
||||
except Exception:
|
||||
logger.exception("Failed to discover OAuth metadata")
|
||||
return None
|
||||
|
||||
async def _register_oauth_client(
|
||||
self,
|
||||
server_url: str,
|
||||
client_metadata: OAuthClientMetadata,
|
||||
metadata: OAuthMetadata | None = None,
|
||||
) -> OAuthClientInformationFull:
|
||||
"""
|
||||
Register OAuth client with server.
|
||||
"""
|
||||
if not metadata:
|
||||
metadata = await self._discover_oauth_metadata(server_url)
|
||||
|
||||
if metadata and metadata.registration_endpoint:
|
||||
registration_url = str(metadata.registration_endpoint)
|
||||
else:
|
||||
# Use fallback registration endpoint
|
||||
auth_base_url = self._get_authorization_base_url(server_url)
|
||||
registration_url = urljoin(auth_base_url, "/register")
|
||||
|
||||
# Handle default scope
|
||||
if (
|
||||
client_metadata.scope is None
|
||||
and metadata
|
||||
and metadata.scopes_supported is not None
|
||||
):
|
||||
client_metadata.scope = " ".join(metadata.scopes_supported)
|
||||
|
||||
# Serialize client metadata
|
||||
registration_data = client_metadata.model_dump(
|
||||
by_alias=True, mode="json", exclude_none=True
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_url,
|
||||
json=registration_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Registration failed: {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
response_data = response.json()
|
||||
logger.debug(f"Registration successful: {response_data}")
|
||||
return OAuthClientInformationFull.model_validate(response_data)
|
||||
|
||||
except httpx.HTTPStatusError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Registration error")
|
||||
raise
|
||||
|
||||
async def async_auth_flow(
|
||||
self, request: httpx.Request
|
||||
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||
"""
|
||||
HTTPX auth flow integration.
|
||||
"""
|
||||
|
||||
if not self._has_valid_token():
|
||||
await self.initialize()
|
||||
await self.ensure_token()
|
||||
# Add Bearer token if available
|
||||
if self._current_tokens and self._current_tokens.access_token:
|
||||
request.headers["Authorization"] = (
|
||||
f"Bearer {self._current_tokens.access_token}"
|
||||
)
|
||||
|
||||
response = yield request
|
||||
|
||||
# Clear token on 401 to trigger re-auth
|
||||
if response.status_code == 401:
|
||||
self._current_tokens = None
|
||||
|
||||
def _has_valid_token(self) -> bool:
|
||||
"""Check if current token is valid."""
|
||||
if not self._current_tokens or not self._current_tokens.access_token:
|
||||
return False
|
||||
|
||||
# Check expiry time
|
||||
if self._token_expiry_time and time.time() > self._token_expiry_time:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _validate_token_scopes(self, token_response: OAuthToken) -> None:
|
||||
"""
|
||||
Validate returned scopes against requested scopes.
|
||||
|
||||
Per OAuth 2.1 Section 3.3: server may grant subset, not superset.
|
||||
"""
|
||||
if not token_response.scope:
|
||||
# No scope returned = validation passes
|
||||
return
|
||||
|
||||
# Check explicitly requested scopes only
|
||||
requested_scopes: set[str] = set()
|
||||
|
||||
if self.client_metadata.scope:
|
||||
# Validate against explicit scope request
|
||||
requested_scopes = set(self.client_metadata.scope.split())
|
||||
|
||||
# Check for unauthorized scopes
|
||||
returned_scopes = set(token_response.scope.split())
|
||||
unauthorized_scopes = returned_scopes - requested_scopes
|
||||
|
||||
if unauthorized_scopes:
|
||||
raise Exception(
|
||||
f"Server granted unauthorized scopes: {unauthorized_scopes}. "
|
||||
f"Requested: {requested_scopes}, Returned: {returned_scopes}"
|
||||
)
|
||||
else:
|
||||
# No explicit scopes requested - accept server defaults
|
||||
logger.debug(
|
||||
f"No explicit scopes requested, accepting server-granted "
|
||||
f"scopes: {set(token_response.scope.split())}"
|
||||
)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load stored tokens and client info."""
|
||||
self._current_tokens = await self.storage.get_tokens()
|
||||
self._client_info = await self.storage.get_client_info()
|
||||
|
||||
async def _get_or_register_client(self) -> OAuthClientInformationFull:
|
||||
"""Get or register client with server."""
|
||||
if not self._client_info:
|
||||
try:
|
||||
self._client_info = await self._register_oauth_client(
|
||||
self.server_url, self.client_metadata, self._metadata
|
||||
)
|
||||
await self.storage.set_client_info(self._client_info)
|
||||
except Exception:
|
||||
logger.exception("Client registration failed")
|
||||
raise
|
||||
return self._client_info
|
||||
|
||||
async def ensure_token(self) -> None:
|
||||
"""Ensure valid access token, refreshing or re-authenticating as needed."""
|
||||
async with self._token_lock:
|
||||
# Return early if token is valid
|
||||
if self._has_valid_token():
|
||||
return
|
||||
|
||||
# Try refreshing existing token
|
||||
if (
|
||||
self._current_tokens
|
||||
and self._current_tokens.refresh_token
|
||||
and await self._refresh_access_token()
|
||||
):
|
||||
return
|
||||
|
||||
# Fall back to full OAuth flow
|
||||
await self._perform_oauth_flow()
|
||||
|
||||
async def _perform_oauth_flow(self) -> None:
|
||||
"""Execute OAuth2 authorization code flow with PKCE."""
|
||||
logger.debug("Starting authentication flow.")
|
||||
|
||||
# Discover OAuth metadata
|
||||
if not self._metadata:
|
||||
self._metadata = await self._discover_oauth_metadata(self.server_url)
|
||||
|
||||
# Ensure client registration
|
||||
client_info = await self._get_or_register_client()
|
||||
|
||||
# Generate PKCE challenge
|
||||
self._code_verifier = self._generate_code_verifier()
|
||||
self._code_challenge = self._generate_code_challenge(self._code_verifier)
|
||||
|
||||
# Get authorization endpoint
|
||||
if self._metadata and self._metadata.authorization_endpoint:
|
||||
auth_url_base = str(self._metadata.authorization_endpoint)
|
||||
else:
|
||||
# Use fallback authorization endpoint
|
||||
auth_base_url = self._get_authorization_base_url(self.server_url)
|
||||
auth_url_base = urljoin(auth_base_url, "/authorize")
|
||||
|
||||
# Build authorization URL
|
||||
self._auth_state = secrets.token_urlsafe(32)
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_info.client_id,
|
||||
"redirect_uri": str(self.client_metadata.redirect_uris[0]),
|
||||
"state": self._auth_state,
|
||||
"code_challenge": self._code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
# Include explicit scopes only
|
||||
if self.client_metadata.scope:
|
||||
auth_params["scope"] = self.client_metadata.scope
|
||||
|
||||
auth_url = f"{auth_url_base}?{urlencode(auth_params)}"
|
||||
|
||||
# Redirect user for authorization
|
||||
await self.redirect_handler(auth_url)
|
||||
|
||||
auth_code, returned_state = await self.callback_handler()
|
||||
|
||||
# Validate state parameter for CSRF protection
|
||||
if returned_state is None or not secrets.compare_digest(
|
||||
returned_state, self._auth_state
|
||||
):
|
||||
raise Exception(
|
||||
f"State parameter mismatch: {returned_state} != {self._auth_state}"
|
||||
)
|
||||
|
||||
# Clear state after validation
|
||||
self._auth_state = None
|
||||
|
||||
if not auth_code:
|
||||
raise Exception("No authorization code received")
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
await self._exchange_code_for_token(auth_code, client_info)
|
||||
|
||||
async def _exchange_code_for_token(
|
||||
self, auth_code: str, client_info: OAuthClientInformationFull
|
||||
) -> None:
|
||||
"""Exchange authorization code for access token."""
|
||||
# Get token endpoint
|
||||
if self._metadata and self._metadata.token_endpoint:
|
||||
token_url = str(self._metadata.token_endpoint)
|
||||
else:
|
||||
# Use fallback token endpoint
|
||||
auth_base_url = self._get_authorization_base_url(self.server_url)
|
||||
token_url = urljoin(auth_base_url, "/token")
|
||||
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": str(self.client_metadata.redirect_uris[0]),
|
||||
"client_id": client_info.client_id,
|
||||
"code_verifier": self._code_verifier,
|
||||
}
|
||||
|
||||
if client_info.client_secret:
|
||||
token_data["client_secret"] = client_info.client_secret
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
token_url,
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Parse OAuth error response
|
||||
try:
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get(
|
||||
"error_description", error_data.get("error", "Unknown error")
|
||||
)
|
||||
raise Exception(
|
||||
f"Token exchange failed: {error_msg} "
|
||||
f"(HTTP {response.status_code})"
|
||||
)
|
||||
except Exception:
|
||||
raise Exception(
|
||||
f"Token exchange failed: {response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
# Parse token response
|
||||
token_response = OAuthToken.model_validate(response.json())
|
||||
|
||||
# Validate token scopes
|
||||
await self._validate_token_scopes(token_response)
|
||||
|
||||
# Calculate token expiry
|
||||
if token_response.expires_in:
|
||||
self._token_expiry_time = time.time() + token_response.expires_in
|
||||
else:
|
||||
self._token_expiry_time = None
|
||||
|
||||
# Store tokens
|
||||
await self.storage.set_tokens(token_response)
|
||||
self._current_tokens = token_response
|
||||
|
||||
async def _refresh_access_token(self) -> bool:
|
||||
"""Refresh access token using refresh token."""
|
||||
if not self._current_tokens or not self._current_tokens.refresh_token:
|
||||
return False
|
||||
|
||||
# Get client credentials
|
||||
client_info = await self._get_or_register_client()
|
||||
|
||||
# Get token endpoint
|
||||
if self._metadata and self._metadata.token_endpoint:
|
||||
token_url = str(self._metadata.token_endpoint)
|
||||
else:
|
||||
# Use fallback token endpoint
|
||||
auth_base_url = self._get_authorization_base_url(self.server_url)
|
||||
token_url = urljoin(auth_base_url, "/token")
|
||||
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self._current_tokens.refresh_token,
|
||||
"client_id": client_info.client_id,
|
||||
}
|
||||
|
||||
if client_info.client_secret:
|
||||
refresh_data["client_secret"] = client_info.client_secret
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
token_url,
|
||||
data=refresh_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Token refresh failed: {response.status_code}")
|
||||
return False
|
||||
|
||||
# Parse refreshed tokens
|
||||
token_response = OAuthToken.model_validate(response.json())
|
||||
|
||||
# Validate token scopes
|
||||
await self._validate_token_scopes(token_response)
|
||||
|
||||
# Calculate token expiry
|
||||
if token_response.expires_in:
|
||||
self._token_expiry_time = time.time() + token_response.expires_in
|
||||
else:
|
||||
self._token_expiry_time = None
|
||||
|
||||
# Store refreshed tokens
|
||||
await self.storage.set_tokens(token_response)
|
||||
self._current_tokens = token_response
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Token refresh failed")
|
||||
return False
|
||||
@@ -83,6 +83,7 @@ class StreamableHTTPTransport:
|
||||
headers: dict[str, Any] | None = None,
|
||||
timeout: timedelta = timedelta(seconds=30),
|
||||
sse_read_timeout: timedelta = timedelta(seconds=60 * 5),
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> None:
|
||||
"""Initialize the StreamableHTTP transport.
|
||||
|
||||
@@ -91,11 +92,13 @@ class StreamableHTTPTransport:
|
||||
headers: Optional headers to include in requests.
|
||||
timeout: HTTP timeout for regular operations.
|
||||
sse_read_timeout: Timeout for SSE read operations.
|
||||
auth: Optional HTTPX authentication handler.
|
||||
"""
|
||||
self.url = url
|
||||
self.headers = headers or {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
self.auth = auth
|
||||
self.session_id: str | None = None
|
||||
self.request_headers = {
|
||||
ACCEPT: f"{JSON}, {SSE}",
|
||||
@@ -427,6 +430,7 @@ async def streamablehttp_client(
|
||||
timeout: timedelta = timedelta(seconds=30),
|
||||
sse_read_timeout: timedelta = timedelta(seconds=60 * 5),
|
||||
terminate_on_close: bool = True,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> AsyncGenerator[
|
||||
tuple[
|
||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
@@ -447,7 +451,7 @@ async def streamablehttp_client(
|
||||
- write_stream: Stream for sending messages to the server
|
||||
- get_session_id_callback: Function to retrieve the current session ID
|
||||
"""
|
||||
transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout)
|
||||
transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout, auth)
|
||||
|
||||
read_stream_writer, read_stream = anyio.create_memory_object_stream[
|
||||
SessionMessage | Exception
|
||||
@@ -465,6 +469,7 @@ async def streamablehttp_client(
|
||||
timeout=httpx.Timeout(
|
||||
transport.timeout.seconds, read=transport.sse_read_timeout.seconds
|
||||
),
|
||||
auth=transport.auth,
|
||||
) as client:
|
||||
# Define callbacks that need access to tg
|
||||
def start_get_stream() -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ __all__ = ["create_mcp_http_client"]
|
||||
def create_mcp_http_client(
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: httpx.Timeout | None = None,
|
||||
auth: httpx.Auth | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create a standardized httpx AsyncClient with MCP defaults.
|
||||
|
||||
@@ -21,6 +22,7 @@ def create_mcp_http_client(
|
||||
headers: Optional headers to include with all requests.
|
||||
timeout: Request timeout as httpx.Timeout object.
|
||||
Defaults to 30 seconds if not specified.
|
||||
auth: Optional authentication handler.
|
||||
|
||||
Returns:
|
||||
Configured httpx.AsyncClient instance with MCP defaults.
|
||||
@@ -43,6 +45,12 @@ def create_mcp_http_client(
|
||||
timeout = httpx.Timeout(60.0, read=300.0)
|
||||
async with create_mcp_http_client(headers, timeout) as client:
|
||||
response = await client.get("/long-request")
|
||||
|
||||
# With authentication
|
||||
from httpx import BasicAuth
|
||||
auth = BasicAuth(username="user", password="pass")
|
||||
async with create_mcp_http_client(headers, timeout, auth) as client:
|
||||
response = await client.get("/protected-endpoint")
|
||||
"""
|
||||
# Set MCP defaults
|
||||
kwargs: dict[str, Any] = {
|
||||
@@ -59,4 +67,8 @@ def create_mcp_http_client(
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
|
||||
# Handle authentication
|
||||
if auth is not None:
|
||||
kwargs["auth"] = auth
|
||||
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
907
tests/client/test_auth.py
Normal file
907
tests/client/test_auth.py
Normal file
@@ -0,0 +1,907 @@
|
||||
"""
|
||||
Tests for OAuth client authentication implementation.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
OAuthToken,
|
||||
)
|
||||
|
||||
|
||||
class MockTokenStorage:
|
||||
"""Mock token storage for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._tokens: OAuthToken | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self._tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self._tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self._client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self._client_info = client_info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage():
|
||||
return MockTokenStorage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_metadata():
|
||||
return OAuthClientMetadata(
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")],
|
||||
client_name="Test Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
scope="read write",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_metadata():
|
||||
return OAuthMetadata(
|
||||
issuer=AnyHttpUrl("https://auth.example.com"),
|
||||
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
|
||||
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
|
||||
registration_endpoint=AnyHttpUrl("https://auth.example.com/register"),
|
||||
scopes_supported=["read", "write", "admin"],
|
||||
response_types_supported=["code"],
|
||||
grant_types_supported=["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported=["S256"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_client_info():
|
||||
return OAuthClientInformationFull(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")],
|
||||
client_name="Test Client",
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
scope="read write",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_token():
|
||||
return OAuthToken(
|
||||
access_token="test_access_token",
|
||||
token_type="bearer",
|
||||
expires_in=3600,
|
||||
refresh_token="test_refresh_token",
|
||||
scope="read write",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def oauth_provider(client_metadata, mock_storage):
|
||||
async def mock_redirect_handler(url: str) -> None:
|
||||
pass
|
||||
|
||||
async def mock_callback_handler() -> tuple[str, str | None]:
|
||||
return "test_auth_code", "test_state"
|
||||
|
||||
return OAuthClientProvider(
|
||||
server_url="https://api.example.com/v1/mcp",
|
||||
client_metadata=client_metadata,
|
||||
storage=mock_storage,
|
||||
redirect_handler=mock_redirect_handler,
|
||||
callback_handler=mock_callback_handler,
|
||||
)
|
||||
|
||||
|
||||
class TestOAuthClientProvider:
|
||||
"""Test OAuth client provider functionality."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_init(self, oauth_provider, client_metadata, mock_storage):
|
||||
"""Test OAuth provider initialization."""
|
||||
assert oauth_provider.server_url == "https://api.example.com/v1/mcp"
|
||||
assert oauth_provider.client_metadata == client_metadata
|
||||
assert oauth_provider.storage == mock_storage
|
||||
assert oauth_provider.timeout == 300.0
|
||||
|
||||
def test_generate_code_verifier(self, oauth_provider):
|
||||
"""Test PKCE code verifier generation."""
|
||||
verifier = oauth_provider._generate_code_verifier()
|
||||
|
||||
# Check length (128 characters)
|
||||
assert len(verifier) == 128
|
||||
|
||||
# Check charset (RFC 7636: A-Z, a-z, 0-9, "-", ".", "_", "~")
|
||||
allowed_chars = set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
assert set(verifier) <= allowed_chars
|
||||
|
||||
# Check uniqueness (generate multiple and ensure they're different)
|
||||
verifiers = {oauth_provider._generate_code_verifier() for _ in range(10)}
|
||||
assert len(verifiers) == 10
|
||||
|
||||
def test_generate_code_challenge(self, oauth_provider):
|
||||
"""Test PKCE code challenge generation."""
|
||||
verifier = "test_code_verifier_123"
|
||||
challenge = oauth_provider._generate_code_challenge(verifier)
|
||||
|
||||
# Manually calculate expected challenge
|
||||
expected_digest = hashlib.sha256(verifier.encode()).digest()
|
||||
expected_challenge = (
|
||||
base64.urlsafe_b64encode(expected_digest).decode().rstrip("=")
|
||||
)
|
||||
|
||||
assert challenge == expected_challenge
|
||||
|
||||
# Verify it's base64url without padding
|
||||
assert "=" not in challenge
|
||||
assert "+" not in challenge
|
||||
assert "/" not in challenge
|
||||
|
||||
def test_get_authorization_base_url(self, oauth_provider):
|
||||
"""Test authorization base URL extraction."""
|
||||
# Test with path
|
||||
assert (
|
||||
oauth_provider._get_authorization_base_url("https://api.example.com/v1/mcp")
|
||||
== "https://api.example.com"
|
||||
)
|
||||
|
||||
# Test with no path
|
||||
assert (
|
||||
oauth_provider._get_authorization_base_url("https://api.example.com")
|
||||
== "https://api.example.com"
|
||||
)
|
||||
|
||||
# Test with port
|
||||
assert (
|
||||
oauth_provider._get_authorization_base_url(
|
||||
"https://api.example.com:8080/path/to/mcp"
|
||||
)
|
||||
== "https://api.example.com:8080"
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discover_oauth_metadata_success(
|
||||
self, oauth_provider, oauth_metadata
|
||||
):
|
||||
"""Test successful OAuth metadata discovery."""
|
||||
metadata_response = oauth_metadata.model_dump(by_alias=True, mode="json")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = metadata_response
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
result = await oauth_provider._discover_oauth_metadata(
|
||||
"https://api.example.com/v1/mcp"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert (
|
||||
result.authorization_endpoint == oauth_metadata.authorization_endpoint
|
||||
)
|
||||
assert result.token_endpoint == oauth_metadata.token_endpoint
|
||||
|
||||
# Verify correct URL was called
|
||||
mock_client.get.assert_called_once()
|
||||
call_args = mock_client.get.call_args[0]
|
||||
assert (
|
||||
call_args[0]
|
||||
== "https://api.example.com/.well-known/oauth-authorization-server"
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discover_oauth_metadata_not_found(self, oauth_provider):
|
||||
"""Test OAuth metadata discovery when not found."""
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_client.get.return_value = mock_response
|
||||
|
||||
result = await oauth_provider._discover_oauth_metadata(
|
||||
"https://api.example.com/v1/mcp"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_discover_oauth_metadata_cors_fallback(
|
||||
self, oauth_provider, oauth_metadata
|
||||
):
|
||||
"""Test OAuth metadata discovery with CORS fallback."""
|
||||
metadata_response = oauth_metadata.model_dump(by_alias=True, mode="json")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
# First call fails (CORS), second succeeds
|
||||
mock_response_success = Mock()
|
||||
mock_response_success.status_code = 200
|
||||
mock_response_success.json.return_value = metadata_response
|
||||
|
||||
mock_client.get.side_effect = [
|
||||
TypeError("CORS error"), # First call fails
|
||||
mock_response_success, # Second call succeeds
|
||||
]
|
||||
|
||||
result = await oauth_provider._discover_oauth_metadata(
|
||||
"https://api.example.com/v1/mcp"
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_oauth_client_success(
|
||||
self, oauth_provider, oauth_metadata, oauth_client_info
|
||||
):
|
||||
"""Test successful OAuth client registration."""
|
||||
registration_response = oauth_client_info.model_dump(by_alias=True, mode="json")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = registration_response
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
result = await oauth_provider._register_oauth_client(
|
||||
"https://api.example.com/v1/mcp",
|
||||
oauth_provider.client_metadata,
|
||||
oauth_metadata,
|
||||
)
|
||||
|
||||
assert result.client_id == oauth_client_info.client_id
|
||||
assert result.client_secret == oauth_client_info.client_secret
|
||||
|
||||
# Verify correct registration endpoint was used
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args[0][0] == str(oauth_metadata.registration_endpoint)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_oauth_client_fallback_endpoint(
|
||||
self, oauth_provider, oauth_client_info
|
||||
):
|
||||
"""Test OAuth client registration with fallback endpoint."""
|
||||
registration_response = oauth_client_info.model_dump(by_alias=True, mode="json")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = registration_response
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
# Mock metadata discovery to return None (fallback)
|
||||
with patch.object(
|
||||
oauth_provider, "_discover_oauth_metadata", return_value=None
|
||||
):
|
||||
result = await oauth_provider._register_oauth_client(
|
||||
"https://api.example.com/v1/mcp",
|
||||
oauth_provider.client_metadata,
|
||||
None,
|
||||
)
|
||||
|
||||
assert result.client_id == oauth_client_info.client_id
|
||||
|
||||
# Verify fallback endpoint was used
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
assert call_args[0][0] == "https://api.example.com/register"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_register_oauth_client_failure(self, oauth_provider):
|
||||
"""Test OAuth client registration failure."""
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Bad Request"
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
# Mock metadata discovery to return None (fallback)
|
||||
with patch.object(
|
||||
oauth_provider, "_discover_oauth_metadata", return_value=None
|
||||
):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await oauth_provider._register_oauth_client(
|
||||
"https://api.example.com/v1/mcp",
|
||||
oauth_provider.client_metadata,
|
||||
None,
|
||||
)
|
||||
|
||||
def test_has_valid_token_no_token(self, oauth_provider):
|
||||
"""Test token validation with no token."""
|
||||
assert not oauth_provider._has_valid_token()
|
||||
|
||||
def test_has_valid_token_valid(self, oauth_provider, oauth_token):
|
||||
"""Test token validation with valid token."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._token_expiry_time = time.time() + 3600 # Future expiry
|
||||
|
||||
assert oauth_provider._has_valid_token()
|
||||
|
||||
def test_has_valid_token_expired(self, oauth_provider, oauth_token):
|
||||
"""Test token validation with expired token."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._token_expiry_time = time.time() - 3600 # Past expiry
|
||||
|
||||
assert not oauth_provider._has_valid_token()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_token_scopes_no_scope(self, oauth_provider):
|
||||
"""Test scope validation with no scope returned."""
|
||||
token = OAuthToken(access_token="test", token_type="bearer")
|
||||
|
||||
# Should not raise exception
|
||||
await oauth_provider._validate_token_scopes(token)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_token_scopes_valid(self, oauth_provider, client_metadata):
|
||||
"""Test scope validation with valid scopes."""
|
||||
oauth_provider.client_metadata = client_metadata
|
||||
token = OAuthToken(
|
||||
access_token="test",
|
||||
token_type="bearer",
|
||||
scope="read write",
|
||||
)
|
||||
|
||||
# Should not raise exception
|
||||
await oauth_provider._validate_token_scopes(token)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_token_scopes_subset(self, oauth_provider, client_metadata):
|
||||
"""Test scope validation with subset of requested scopes."""
|
||||
oauth_provider.client_metadata = client_metadata
|
||||
token = OAuthToken(
|
||||
access_token="test",
|
||||
token_type="bearer",
|
||||
scope="read",
|
||||
)
|
||||
|
||||
# Should not raise exception (servers can grant subset)
|
||||
await oauth_provider._validate_token_scopes(token)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_token_scopes_unauthorized(
|
||||
self, oauth_provider, client_metadata
|
||||
):
|
||||
"""Test scope validation with unauthorized scopes."""
|
||||
oauth_provider.client_metadata = client_metadata
|
||||
token = OAuthToken(
|
||||
access_token="test",
|
||||
token_type="bearer",
|
||||
scope="read write admin", # Includes unauthorized "admin"
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Server granted unauthorized scopes"):
|
||||
await oauth_provider._validate_token_scopes(token)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validate_token_scopes_no_requested(self, oauth_provider):
|
||||
"""Test scope validation with no requested scopes accepts any server scopes."""
|
||||
# No scope in client metadata
|
||||
oauth_provider.client_metadata.scope = None
|
||||
token = OAuthToken(
|
||||
access_token="test",
|
||||
token_type="bearer",
|
||||
scope="admin super",
|
||||
)
|
||||
|
||||
# Should not raise exception when no scopes were explicitly requested
|
||||
# (accepts server defaults)
|
||||
await oauth_provider._validate_token_scopes(token)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initialize(
|
||||
self, oauth_provider, mock_storage, oauth_token, oauth_client_info
|
||||
):
|
||||
"""Test initialization loading from storage."""
|
||||
mock_storage._tokens = oauth_token
|
||||
mock_storage._client_info = oauth_client_info
|
||||
|
||||
await oauth_provider.initialize()
|
||||
|
||||
assert oauth_provider._current_tokens == oauth_token
|
||||
assert oauth_provider._client_info == oauth_client_info
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_or_register_client_existing(
|
||||
self, oauth_provider, oauth_client_info
|
||||
):
|
||||
"""Test getting existing client info."""
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
result = await oauth_provider._get_or_register_client()
|
||||
|
||||
assert result == oauth_client_info
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_or_register_client_register_new(
|
||||
self, oauth_provider, oauth_client_info
|
||||
):
|
||||
"""Test registering new client."""
|
||||
with patch.object(
|
||||
oauth_provider, "_register_oauth_client", return_value=oauth_client_info
|
||||
) as mock_register:
|
||||
result = await oauth_provider._get_or_register_client()
|
||||
|
||||
assert result == oauth_client_info
|
||||
assert oauth_provider._client_info == oauth_client_info
|
||||
mock_register.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_exchange_code_for_token_success(
|
||||
self, oauth_provider, oauth_client_info, oauth_token
|
||||
):
|
||||
"""Test successful code exchange for token."""
|
||||
oauth_provider._code_verifier = "test_verifier"
|
||||
token_response = oauth_token.model_dump(by_alias=True, mode="json")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = token_response
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch.object(
|
||||
oauth_provider, "_validate_token_scopes"
|
||||
) as mock_validate:
|
||||
await oauth_provider._exchange_code_for_token(
|
||||
"test_auth_code", oauth_client_info
|
||||
)
|
||||
|
||||
assert (
|
||||
oauth_provider._current_tokens.access_token
|
||||
== oauth_token.access_token
|
||||
)
|
||||
mock_validate.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_exchange_code_for_token_failure(
|
||||
self, oauth_provider, oauth_client_info
|
||||
):
|
||||
"""Test failed code exchange for token."""
|
||||
oauth_provider._code_verifier = "test_verifier"
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Invalid grant"
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with pytest.raises(Exception, match="Token exchange failed"):
|
||||
await oauth_provider._exchange_code_for_token(
|
||||
"invalid_auth_code", oauth_client_info
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_refresh_access_token_success(
|
||||
self, oauth_provider, oauth_client_info, oauth_token
|
||||
):
|
||||
"""Test successful token refresh."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
new_token = OAuthToken(
|
||||
access_token="new_access_token",
|
||||
token_type="bearer",
|
||||
expires_in=3600,
|
||||
refresh_token="new_refresh_token",
|
||||
scope="read write",
|
||||
)
|
||||
token_response = new_token.model_dump(by_alias=True, mode="json")
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = token_response
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with patch.object(
|
||||
oauth_provider, "_validate_token_scopes"
|
||||
) as mock_validate:
|
||||
result = await oauth_provider._refresh_access_token()
|
||||
|
||||
assert result is True
|
||||
assert (
|
||||
oauth_provider._current_tokens.access_token
|
||||
== new_token.access_token
|
||||
)
|
||||
mock_validate.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_refresh_access_token_no_refresh_token(self, oauth_provider):
|
||||
"""Test token refresh with no refresh token."""
|
||||
oauth_provider._current_tokens = OAuthToken(
|
||||
access_token="test",
|
||||
token_type="bearer",
|
||||
# No refresh_token
|
||||
)
|
||||
|
||||
result = await oauth_provider._refresh_access_token()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_refresh_access_token_failure(
|
||||
self, oauth_provider, oauth_client_info, oauth_token
|
||||
):
|
||||
"""Test failed token refresh."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 400
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
result = await oauth_provider._refresh_access_token()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_perform_oauth_flow_success(
|
||||
self, oauth_provider, oauth_metadata, oauth_client_info
|
||||
):
|
||||
"""Test successful OAuth flow."""
|
||||
oauth_provider._metadata = oauth_metadata
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
# Mock the redirect handler to capture the auth URL
|
||||
auth_url_captured = None
|
||||
|
||||
async def mock_redirect_handler(url: str) -> None:
|
||||
nonlocal auth_url_captured
|
||||
auth_url_captured = url
|
||||
|
||||
oauth_provider.redirect_handler = mock_redirect_handler
|
||||
|
||||
# Mock callback handler with matching state
|
||||
async def mock_callback_handler() -> tuple[str, str | None]:
|
||||
# Extract state from auth URL to return matching value
|
||||
if auth_url_captured:
|
||||
parsed_url = urlparse(auth_url_captured)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
state = query_params.get("state", [None])[0]
|
||||
return "test_auth_code", state
|
||||
return "test_auth_code", "test_state"
|
||||
|
||||
oauth_provider.callback_handler = mock_callback_handler
|
||||
|
||||
with patch.object(oauth_provider, "_exchange_code_for_token") as mock_exchange:
|
||||
await oauth_provider._perform_oauth_flow()
|
||||
|
||||
# Verify auth URL was generated correctly
|
||||
assert auth_url_captured is not None
|
||||
parsed_url = urlparse(auth_url_captured)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
assert query_params["response_type"][0] == "code"
|
||||
assert query_params["client_id"][0] == oauth_client_info.client_id
|
||||
assert query_params["code_challenge_method"][0] == "S256"
|
||||
assert "code_challenge" in query_params
|
||||
assert "state" in query_params
|
||||
|
||||
# Verify code exchange was called
|
||||
mock_exchange.assert_called_once_with("test_auth_code", oauth_client_info)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_perform_oauth_flow_state_mismatch(
|
||||
self, oauth_provider, oauth_metadata, oauth_client_info
|
||||
):
|
||||
"""Test OAuth flow with state parameter mismatch."""
|
||||
oauth_provider._metadata = oauth_metadata
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
# Mock callback handler to return mismatched state
|
||||
async def mock_callback_handler() -> tuple[str, str | None]:
|
||||
return "test_auth_code", "wrong_state"
|
||||
|
||||
oauth_provider.callback_handler = mock_callback_handler
|
||||
|
||||
async def mock_redirect_handler(url: str) -> None:
|
||||
pass
|
||||
|
||||
oauth_provider.redirect_handler = mock_redirect_handler
|
||||
|
||||
with pytest.raises(Exception, match="State parameter mismatch"):
|
||||
await oauth_provider._perform_oauth_flow()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ensure_token_existing_valid(self, oauth_provider, oauth_token):
|
||||
"""Test ensure_token with existing valid token."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._token_expiry_time = time.time() + 3600
|
||||
|
||||
await oauth_provider.ensure_token()
|
||||
|
||||
# Should not trigger new auth flow
|
||||
assert oauth_provider._current_tokens == oauth_token
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ensure_token_refresh(self, oauth_provider, oauth_token):
|
||||
"""Test ensure_token with expired token that can be refreshed."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._token_expiry_time = time.time() - 3600 # Expired
|
||||
|
||||
with patch.object(
|
||||
oauth_provider, "_refresh_access_token", return_value=True
|
||||
) as mock_refresh:
|
||||
await oauth_provider.ensure_token()
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ensure_token_full_flow(self, oauth_provider):
|
||||
"""Test ensure_token triggering full OAuth flow."""
|
||||
# No existing token
|
||||
with patch.object(oauth_provider, "_perform_oauth_flow") as mock_flow:
|
||||
await oauth_provider.ensure_token()
|
||||
mock_flow.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_auth_flow_add_token(self, oauth_provider, oauth_token):
|
||||
"""Test async auth flow adding Bearer token to request."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._token_expiry_time = time.time() + 3600
|
||||
|
||||
request = httpx.Request("GET", "https://api.example.com/data")
|
||||
|
||||
# Mock response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
auth_flow = oauth_provider.async_auth_flow(request)
|
||||
updated_request = await auth_flow.__anext__()
|
||||
|
||||
assert (
|
||||
updated_request.headers["Authorization"]
|
||||
== f"Bearer {oauth_token.access_token}"
|
||||
)
|
||||
|
||||
# Send mock response
|
||||
try:
|
||||
await auth_flow.asend(mock_response)
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_auth_flow_401_response(self, oauth_provider, oauth_token):
|
||||
"""Test async auth flow handling 401 response."""
|
||||
oauth_provider._current_tokens = oauth_token
|
||||
oauth_provider._token_expiry_time = time.time() + 3600
|
||||
|
||||
request = httpx.Request("GET", "https://api.example.com/data")
|
||||
|
||||
# Mock 401 response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
auth_flow = oauth_provider.async_auth_flow(request)
|
||||
await auth_flow.__anext__()
|
||||
|
||||
# Send 401 response
|
||||
try:
|
||||
await auth_flow.asend(mock_response)
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
# Should clear current tokens
|
||||
assert oauth_provider._current_tokens is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_auth_flow_no_token(self, oauth_provider):
|
||||
"""Test async auth flow with no token triggers auth flow."""
|
||||
request = httpx.Request("GET", "https://api.example.com/data")
|
||||
|
||||
with (
|
||||
patch.object(oauth_provider, "initialize") as mock_init,
|
||||
patch.object(oauth_provider, "ensure_token") as mock_ensure,
|
||||
):
|
||||
auth_flow = oauth_provider.async_auth_flow(request)
|
||||
updated_request = await auth_flow.__anext__()
|
||||
|
||||
mock_init.assert_called_once()
|
||||
mock_ensure.assert_called_once()
|
||||
|
||||
# No Authorization header should be added if no token
|
||||
assert "Authorization" not in updated_request.headers
|
||||
|
||||
def test_scope_priority_client_metadata_first(
|
||||
self, oauth_provider, oauth_client_info
|
||||
):
|
||||
"""Test that client metadata scope takes priority."""
|
||||
oauth_provider.client_metadata.scope = "read write"
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
oauth_provider._client_info.scope = "admin"
|
||||
|
||||
# Build auth params to test scope logic
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test_client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"state": "test_state",
|
||||
"code_challenge": "test_challenge",
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
# Apply scope logic from _perform_oauth_flow
|
||||
if oauth_provider.client_metadata.scope:
|
||||
auth_params["scope"] = oauth_provider.client_metadata.scope
|
||||
elif (
|
||||
hasattr(oauth_provider._client_info, "scope")
|
||||
and oauth_provider._client_info.scope
|
||||
):
|
||||
auth_params["scope"] = oauth_provider._client_info.scope
|
||||
|
||||
assert auth_params["scope"] == "read write"
|
||||
|
||||
def test_scope_priority_no_client_metadata_scope(
|
||||
self, oauth_provider, oauth_client_info
|
||||
):
|
||||
"""Test that no scope parameter is set when client metadata has no scope."""
|
||||
oauth_provider.client_metadata.scope = None
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
oauth_provider._client_info.scope = "admin"
|
||||
|
||||
# Build auth params to test scope logic
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test_client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"state": "test_state",
|
||||
"code_challenge": "test_challenge",
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
# Apply simplified scope logic from _perform_oauth_flow
|
||||
if oauth_provider.client_metadata.scope:
|
||||
auth_params["scope"] = oauth_provider.client_metadata.scope
|
||||
# No fallback to client_info scope in simplified logic
|
||||
|
||||
# No scope should be set since client metadata doesn't have explicit scope
|
||||
assert "scope" not in auth_params
|
||||
|
||||
def test_scope_priority_no_scope(self, oauth_provider, oauth_client_info):
|
||||
"""Test that no scope parameter is set when no scopes specified."""
|
||||
oauth_provider.client_metadata.scope = None
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
oauth_provider._client_info.scope = None
|
||||
|
||||
# Build auth params to test scope logic
|
||||
auth_params = {
|
||||
"response_type": "code",
|
||||
"client_id": "test_client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"state": "test_state",
|
||||
"code_challenge": "test_challenge",
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
|
||||
# Apply scope logic from _perform_oauth_flow
|
||||
if oauth_provider.client_metadata.scope:
|
||||
auth_params["scope"] = oauth_provider.client_metadata.scope
|
||||
elif (
|
||||
hasattr(oauth_provider._client_info, "scope")
|
||||
and oauth_provider._client_info.scope
|
||||
):
|
||||
auth_params["scope"] = oauth_provider._client_info.scope
|
||||
|
||||
# No scope should be set
|
||||
assert "scope" not in auth_params
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_state_parameter_validation_uses_constant_time(
|
||||
self, oauth_provider, oauth_metadata, oauth_client_info
|
||||
):
|
||||
"""Test that state parameter validation uses constant-time comparison."""
|
||||
oauth_provider._metadata = oauth_metadata
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
# Mock callback handler to return mismatched state
|
||||
async def mock_callback_handler() -> tuple[str, str | None]:
|
||||
return "test_auth_code", "wrong_state"
|
||||
|
||||
oauth_provider.callback_handler = mock_callback_handler
|
||||
|
||||
async def mock_redirect_handler(url: str) -> None:
|
||||
pass
|
||||
|
||||
oauth_provider.redirect_handler = mock_redirect_handler
|
||||
|
||||
# Patch secrets.compare_digest to verify it's being called
|
||||
with patch(
|
||||
"mcp.client.auth.secrets.compare_digest", return_value=False
|
||||
) as mock_compare:
|
||||
with pytest.raises(Exception, match="State parameter mismatch"):
|
||||
await oauth_provider._perform_oauth_flow()
|
||||
|
||||
# Verify constant-time comparison was used
|
||||
mock_compare.assert_called_once()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_state_parameter_validation_none_state(
|
||||
self, oauth_provider, oauth_metadata, oauth_client_info
|
||||
):
|
||||
"""Test that None state is handled correctly."""
|
||||
oauth_provider._metadata = oauth_metadata
|
||||
oauth_provider._client_info = oauth_client_info
|
||||
|
||||
# Mock callback handler to return None state
|
||||
async def mock_callback_handler() -> tuple[str, str | None]:
|
||||
return "test_auth_code", None
|
||||
|
||||
oauth_provider.callback_handler = mock_callback_handler
|
||||
|
||||
async def mock_redirect_handler(url: str) -> None:
|
||||
pass
|
||||
|
||||
oauth_provider.redirect_handler = mock_redirect_handler
|
||||
|
||||
with pytest.raises(Exception, match="State parameter mismatch"):
|
||||
await oauth_provider._perform_oauth_flow()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_exchange_error_basic(self, oauth_provider, oauth_client_info):
|
||||
"""Test token exchange error handling (basic)."""
|
||||
oauth_provider._code_verifier = "test_verifier"
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
# Mock error response
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Bad Request"
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
with pytest.raises(Exception, match="Token exchange failed"):
|
||||
await oauth_provider._exchange_code_for_token(
|
||||
"invalid_auth_code", oauth_client_info
|
||||
)
|
||||
Reference in New Issue
Block a user