feat: connect to MCP servers that speak protocol version 2026-07-28 (#33087)

Upgrade the MCP SDK to 2.3.0 and build MCPClient on its Client in
mode='auto', which probes server/discover for the stateless 2026-07-28
protocol and falls back to the initialize handshake for legacy servers.
MCPClient's public surface is unchanged.

MCP_INITIALIZE_TIMEOUT becomes the session's default request timeout, so
it bounds the legacy handshake, tool listing and resource reads; tool
calls keep using AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER.

Relates to #29778

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Wilsdon 2026-10-24 15:41:10 -04:00 • committed by GitHub
parent ea615c3a2e
commit 8151826483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 146 additions and 113 deletions

View file

@ -699,9 +699,11 @@ else:
except Exception:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
# Timeout (in seconds) for the MCP session.initialize() handshake.
# The handshake performs a list-tools round-trip and can take tens of
# seconds on cold-start servers or servers exposing many tools.
# Timeout (in seconds) for each MCP protocol request other than a tool call:
# the legacy initialize handshake, tool listing and resource reads. Raise it
# for cold-start servers or servers exposing many tools. The SDK bounds its
# server/discover probe separately (10s), and tool calls use
# AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER.
MCP_INITIALIZE_TIMEOUT = os.getenv('MCP_INITIALIZE_TIMEOUT', '10')
try:
MCP_INITIALIZE_TIMEOUT = int(MCP_INITIALIZE_TIMEOUT)

View file

@ -1,68 +1,47 @@
import asyncio
import logging
import math
from contextlib import AsyncExitStack
from datetime import timedelta
from typing import Optional
from urllib.parse import urlsplit
log = logging.getLogger(__name__)
import anyio
import httpx
import httpx2
from jsonschema import Draft202012Validator, FormatChecker
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import ElicitRequestFormParams, ElicitResult
from referencing import Registry
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
MCP_INITIALIZE_TIMEOUT,
)
from open_webui.utils.json_codec import JSONCodec
from referencing import Registry
log = logging.getLogger(__name__)
def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True):
"""Create an httpx AsyncClient for MCP transport.
def create_httpx_client(headers=None, auth=None):
"""Create the httpx2 AsyncClient that carries MCP traffic.
Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller
(i.e. the MCP SDK) does not supply an explicit timeout.
Note: verify must be passed at construction time because httpx2
configures the SSL context during __init__. AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
may be True, False, or an ssl.SSLContext (when a custom CA bundle path is
configured); verify= accepts all three.
Note: verify must be passed at construction time because httpx
configures the SSL context during __init__. Setting client.verify = False
after construction does not affect the underlying transport's SSL context.
Redirects are not configured here: the MCP SDK follows same-origin
redirects itself and ignores the client's follow_redirects setting.
"""
kwargs = {
'follow_redirects': True,
'verify': verify,
}
if timeout is not None:
kwargs['timeout'] = timeout
elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None:
kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
if headers is not None:
kwargs['headers'] = headers
if auth is not None:
kwargs['auth'] = auth
return httpx.AsyncClient(**kwargs)
return httpx2.AsyncClient(
headers=headers,
auth=auth,
verify=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
# The SDK's transport defaults: reads stay open for long-lived SSE streams.
timeout=httpx2.Timeout(30, read=300),
)
def create_httpx_client(headers=None, timeout=None, auth=None):
# AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL may be True, False, or an
# ssl.SSLContext (when a custom CA bundle path is configured).
# httpx's verify= accepts bool | str | ssl.SSLContext, so all three work.
ssl_setting = AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
verify = ssl_setting if ssl_setting is not True else True
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=verify)
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
class OAuthTokenAuth(httpx.Auth):
class OAuthTokenAuth(httpx2.Auth):
"""Resolve current credentials per request and recover from concurrent token rotation."""
requires_request_body = True
@ -71,15 +50,15 @@ class OAuthTokenAuth(httpx.Auth):
self.get_headers = get_headers
async def async_auth_flow(self, request):
headers = httpx.Headers(await self.get_headers())
headers = httpx2.Headers(await self.get_headers())
authorization = headers.get('Authorization')
if not authorization:
raise httpx.RequestError('No OAuth access token available', request=request)
raise httpx2.RequestError('No OAuth access token available', request=request)
request.headers.update(headers)
response = yield request
if response.status_code == 401:
headers = httpx.Headers(await self.get_headers())
headers = httpx2.Headers(await self.get_headers())
if headers.get('Authorization') and headers['Authorization'] != authorization:
request.headers.update(headers)
yield request
@ -87,7 +66,7 @@ class OAuthTokenAuth(httpx.Auth):
class MCPClient:
def __init__(self, event_caller=None, server_name=''):
self.session: Optional[ClientSession] = None
self.client: Optional[Client] = None
self.exit_stack = None
self.event_caller = event_caller
self.server_name = server_name
@ -96,7 +75,7 @@ class MCPClient:
async def elicit(self, context, params):
try:
if isinstance(params, ElicitRequestFormParams):
Draft202012Validator.check_schema(params.requestedSchema)
Draft202012Validator.check_schema(params.requested_schema)
else:
url = urlsplit(params.url)
if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password:
@ -106,7 +85,9 @@ class MCPClient:
{
'type': 'request:elicitation',
'data': {
**params.model_dump(mode='json', include={'mode', 'message', 'requestedSchema', 'url'}),
**params.model_dump(
mode='json', by_alias=True, include={'mode', 'message', 'requested_schema', 'url'}
),
'server_name': self.server_name,
},
}
@ -119,51 +100,45 @@ class MCPClient:
content = response.get('content')
# Never fetch server-supplied schema references or coerce user answers.
Draft202012Validator(
params.requestedSchema, format_checker=FormatChecker(), registry=Registry()
params.requested_schema, format_checker=FormatChecker(), registry=Registry()
).validate(content)
return ElicitResult(action=response['action'], content=content)
except Exception as e:
log.warning('MCP elicitation failed: %s', type(e).__name__)
return ElicitResult(action='cancel')
async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx.Auth] = None):
async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx2.Auth] = None):
async with AsyncExitStack() as exit_stack:
try:
self._streams_context = streamablehttp_client(
url,
headers=headers,
auth=auth,
httpx_client_factory=create_httpx_client
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
else create_insecure_httpx_client,
http_client = await exit_stack.enter_async_context(create_httpx_client(headers=headers, auth=auth))
# mode='auto' (the default) probes server/discover for the stateless
# 2026-07-28 protocol and falls back to the initialize handshake on
# legacy servers. The read timeout bounds that handshake and every
# later request that does not set its own; the SDK caps the probe itself.
self.client = await exit_stack.enter_async_context(
Client(
streamable_http_client(url, http_client=http_client),
read_timeout_seconds=MCP_INITIALIZE_TIMEOUT,
elicitation_callback=self.elicit if self.event_caller else None,
)
)
transport = await exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
self._session_context = ClientSession(
read_stream, write_stream, elicitation_callback=self.elicit if self.event_caller else None
)
self.session = await exit_stack.enter_async_context(self._session_context)
with anyio.fail_after(MCP_INITIALIZE_TIMEOUT):
result = await self.session.initialize()
self.instructions = result.instructions
self.instructions = self.client.instructions
self.exit_stack = exit_stack.pop_all()
except Exception as e:
await self.disconnect()
raise e
async def list_tool_specs(self) -> Optional[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
tools = []
cursor = None
while True:
result = await self.session.list_tools(cursor=cursor)
result = await self.client.list_tools(cursor=cursor)
tools.extend(result.tools)
cursor = result.nextCursor
cursor = result.next_cursor
if cursor is None:
break
@ -172,31 +147,31 @@ class MCPClient:
name = tool.name
description = tool.description
inputSchema = tool.inputSchema
input_schema = tool.input_schema
# TODO: handle outputSchema if needed
outputSchema = getattr(tool, 'outputSchema', None)
# TODO: handle output_schema if needed
tool_specs.append({'name': name, 'description': description, 'parameters': inputSchema})
tool_specs.append({'name': name, 'description': description, 'parameters': input_schema})
return tool_specs
async def call_tool(self, function_name: str, function_args: dict) -> list[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
tool_call_timeout = None
# math.inf is the SDK's own "no timeout"; None would fall back to MCP_INITIALIZE_TIMEOUT.
tool_call_timeout = math.inf
if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None and AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER > 0:
tool_call_timeout = timedelta(seconds=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
tool_call_timeout = AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER
result = await self.session.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout)
result = await self.client.call_tool(function_name, function_args, read_timeout_seconds=tool_call_timeout)
if not result:
raise Exception('No result returned from MCP tool call.')
result_dict = result.model_dump(mode='json')
result_dict = result.model_dump(mode='json', by_alias=True)
result_content = result_dict['content']
if result.isError:
if result.is_error:
raise Exception(result_content)
structured_content = result_dict.get('structuredContent')
@ -238,26 +213,26 @@ class MCPClient:
return result_content
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
result = await self.session.list_resources(cursor=cursor)
result = await self.client.list_resources(cursor=cursor)
if not result:
raise Exception('No result returned from MCP list_resources call.')
result_dict = result.model_dump()
result_dict = result.model_dump(by_alias=True)
resources = result_dict.get('resources', [])
return resources
async def read_resource(self, uri: str) -> Optional[dict]:
if not self.session:
if not self.client:
raise RuntimeError('MCP client is not connected.')
result = await self.session.read_resource(uri)
result = await self.client.read_resource(uri)
if not result:
raise Exception('No result returned from MCP read_resource call.')
result_dict = result.model_dump()
result_dict = result.model_dump(by_alias=True)
return result_dict
@ -273,7 +248,7 @@ class MCPClient:
# Prevent double-close from concurrent callers
self.exit_stack = None
self.session = None
self.client = None
try:
# IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for()

View file

@ -30,6 +30,7 @@ aiofiles==25.1.0
starlette-compress==1.7.1
Brotli==1.2.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1
httpx2==2.16.0
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
@ -50,7 +51,7 @@ asgiref==3.11.1
# AI libraries
tiktoken==0.13.0
mcp==1.27.2
mcp==2.3.0
openai==2.29.0

View file

@ -26,6 +26,7 @@ starlette-compress==1.7.1
Brotli==1.2.0
brotlicffi==1.2.0.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1
httpx2==2.16.0
starsessions[redis]==2.2.1
python-mimeparse==2.0.0
@ -45,7 +46,7 @@ asgiref==3.11.1
# AI libraries
tiktoken==0.13.0
mcp==1.27.2
mcp==2.3.0
openai==2.29.0
anthropic==0.86.0

View file

@ -32,6 +32,7 @@ dependencies = [
"Brotli==1.2.0",
"brotlicffi==1.2.0.1",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"httpx2==2.16.0",
"starsessions[redis]==2.2.1",
"python-mimeparse==2.0.0",
@ -53,7 +54,7 @@ dependencies = [
"tiktoken==0.13.0",
"regex==2026.5.9",
"google-re2==1.1.20251105",
"mcp==1.27.2",
"mcp==2.3.0",
"openai==2.29.0",
"anthropic==0.86.0",

85
uv.lock generated
View file

@ -1667,6 +1667,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpcore2"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
@ -1725,12 +1738,29 @@ zstd = [
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
name = "httpx2"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
@ -2233,15 +2263,15 @@ wheels = [
[[package]]
name = "mcp"
version = "1.27.2"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
@ -2251,9 +2281,22 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
]
[[package]]
name = "mcp-types"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
]
[[package]]
@ -2793,6 +2836,7 @@ dependencies = [
{ name = "googleapis-common-protos" },
{ name = "hiredis" },
{ name = "httpx", extra = ["brotli", "cli", "http2", "socks", "zstd"] },
{ name = "httpx2" },
{ name = "itsdangerous" },
{ name = "joserfc" },
{ name = "langchain-classic" },
@ -2922,6 +2966,7 @@ requires-dist = [
{ name = "googleapis-common-protos", specifier = "==1.75.0" },
{ name = "hiredis", specifier = "==3.4.2" },
{ name = "httpx", extras = ["brotli", "cli", "http2", "socks", "zstd"], specifier = "==0.28.1" },
{ name = "httpx2", specifier = "==2.16.0" },
{ name = "itsdangerous", specifier = "==2.2.0" },
{ name = "joserfc", specifier = "==1.7.4" },
{ name = "langchain-classic", specifier = "==1.0.7" },
@ -2932,7 +2977,7 @@ requires-dist = [
{ name = "lxml", specifier = "==6.1.1" },
{ name = "mariadb", marker = "extra == 'mariadb'", specifier = "==1.1.14" },
{ name = "markdown", specifier = "==3.10.2" },
{ name = "mcp", specifier = "==1.27.2" },
{ name = "mcp", specifier = "==2.3.0" },
{ name = "msoffcrypto-tool", specifier = "==6.0.0" },
{ name = "onnxruntime", specifier = "==1.26.0" },
{ name = "openai", specifier = "==2.29.0" },
@ -2991,7 +3036,6 @@ requires-dist = [
{ name = "xlrd", specifier = "==2.0.2" },
{ name = "youtube-transcript-api", specifier = "==1.2.4" },
]
provides-extras = ["all", "mariadb", "postgres", "unstructured"]
[package.metadata.requires-dev]
@ -3988,9 +4032,9 @@ wheels = [
name = "pyotp"
version = "2.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763 }
sdist = { url = "https://files.pythonhosted.org/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63", size = 17763, upload-time = "2023-07-27T23:41:03.295Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376 },
{ url = "https://files.pythonhosted.org/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612", size = 13376, upload-time = "2023-07-27T23:41:01.685Z" },
]
[[package]]
@ -4313,9 +4357,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317 }
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986 },
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
@ -5258,6 +5302,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" },
]
[[package]]
name = "truststore"
version = "0.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
]
[[package]]
name = "typer"
version = "0.25.1"