diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 1f3a719bcf..8c55c783c8 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -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) diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index 09fcacfe7e..d3ec775cbd 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -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() diff --git a/backend/requirements-slim.txt b/backend/requirements-slim.txt index 722e952234..f9bda7004e 100644 --- a/backend/requirements-slim.txt +++ b/backend/requirements-slim.txt @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 73f028f75e..44713848d5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 33533cbc46..38372a5afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/uv.lock b/uv.lock index f72e81bbca..8052885c18 100644 --- a/uv.lock +++ b/uv.lock @@ -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"