From c04e4883c2924021eb6908590a969d27df74cb7e Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 28 Oct 2026 09:21:01 +0100 Subject: [PATCH] MCP: mcp 2.3 (MCPServer, transport arguments on streamable_http_app, bearer read in middleware); tests for the 2026-07-28 revision and the resource check --- backend/fluksio/ai/agent.py | 11 ++-- backend/fluksio/mcp/http.py | 11 ++-- backend/fluksio/mcp/server.py | 59 +++++++++++--------- backend/pyproject.toml | 2 +- backend/tests/mcp/test_mcp_http.py | 74 +++++++++++++++++++++++-- backend/tests/mcp/test_mcp_tools.py | 33 +++++++++++ docs/code/agents.md | 3 +- uv.lock | 86 +++++++++++++++++++++++++---- 8 files changed, 224 insertions(+), 55 deletions(-) diff --git a/backend/fluksio/ai/agent.py b/backend/fluksio/ai/agent.py index 260be83..2f01450 100644 --- a/backend/fluksio/ai/agent.py +++ b/backend/fluksio/ai/agent.py @@ -20,7 +20,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from typing import Any import httpx -from mcp.types import TextContent +from mcp.types import CallToolResult, TextContent from fluksio.ai import client from fluksio.ai.config import AIConfig @@ -68,7 +68,7 @@ async def tool_specs() -> list[dict[str, Any]]: "function": { "name": tool.name, "description": tool.description or "", - "parameters": tool.inputSchema, + "parameters": tool.input_schema, }, } for tool in await mcp.list_tools() @@ -108,10 +108,9 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> str: result = await mcp.call_tool(name, arguments) except Exception as exc: # noqa: BLE001 - an unknown tool, or refused arguments return json.dumps({"error": str(exc)}) - if isinstance(result, dict): - text = json.dumps(result) - else: - text = "".join(c.text for c in result if isinstance(c, TextContent)) + if not isinstance(result, CallToolResult): + return json.dumps({"error": "The tool asked for more input than a chat gives."}) + text = "".join(c.text for c in result.content if isinstance(c, TextContent)) if len(text) > _RESULT_LIMIT: text = text[:_RESULT_LIMIT] + "… (truncated)" return text diff --git a/backend/fluksio/mcp/http.py b/backend/fluksio/mcp/http.py index 9ae2cd0..42f0207 100644 --- a/backend/fluksio/mcp/http.py +++ b/backend/fluksio/mcp/http.py @@ -167,18 +167,21 @@ class _ThroughThePortal: def build_http_app(app: object) -> ASGIApp: """The MCP sub-app, wired to this app's API and its OAuth server.""" - server.mcp.settings.stateless_http = True - server.mcp.settings.json_response = True server.mcp.settings.auth = AuthSettings( issuer_url=AnyHttpUrl(settings.oauth_issuer), resource_server_url=AnyHttpUrl(settings.mcp_resource), required_scopes=None, validate_token_resource=True, ) - server.mcp.settings.transport_security = _transport_security() # The SDK only takes a verifier through its constructor, and the instance is # module-level so the tools can be declared at import time. server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001 server.connect(app) - return _ThroughThePortal(server.mcp.streamable_http_app()) + return _ThroughThePortal( + server.mcp.streamable_http_app( + stateless_http=True, + json_response=True, + transport_security=_transport_security(), + ) + ) diff --git a/backend/fluksio/mcp/server.py b/backend/fluksio/mcp/server.py index 5022c53..c7a03d5 100644 --- a/backend/fluksio/mcp/server.py +++ b/backend/fluksio/mcp/server.py @@ -13,24 +13,46 @@ identity rather than some service account. from __future__ import annotations import logging -import warnings from contextvars import ContextVar from typing import Any import httpx -from mcp.server.fastmcp import FastMCP -from mcp.server.lowlevel.server import request_ctx +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver import MCPServer from fluksio.sdk.client import seg as _seg logger = logging.getLogger(__name__) -# FastMCP's own settings model leaves `lifespan` a forward reference, which -# pydantic-settings warns about on every start. Nothing reads that field from -# the environment, so the warning says nothing about this app. -with warnings.catch_warnings(): - warnings.filterwarnings("ignore", message="Field 'lifespan' has an incomplete") - mcp = FastMCP("fluksio") +#: The ``Authorization`` value tool calls carry onto the REST API. The endpoint's +#: middleware sets it from each MCP request; the assistant sets it to the token of +#: the person chatting, for the length of their turn, since no MCP request is +#: involved there. +caller_authorization: ContextVar[str | None] = ContextVar( + "caller_authorization", default=None +) + + +async def _carry_authorization( + ctx: ServerRequestContext[Any, Any], call_next: CallNext +) -> HandlerResult: + """Make the bearer token of an MCP request the caller's for its tool calls. + + The transport attaches the originating HTTP request to every JSON-RPC + message, so the token is read per message rather than per connection. + """ + headers = getattr(ctx.request, "headers", None) + authorization = headers.get("authorization") if headers else None + if not authorization: + return await call_next(ctx) + token = caller_authorization.set(authorization) + try: + return await call_next(ctx) + finally: + caller_authorization.reset(token) + + +mcp = MCPServer("fluksio", middleware=[_carry_authorization]) #: The tool calls never leave the process, so the host is a label, not a route. _INTERNAL_BASE = "http://fluksio-mcp.internal" @@ -39,13 +61,6 @@ _INTERNAL_BASE = "http://fluksio-mcp.internal" #: hop. Built once by :func:`connect`, for the endpoint and the assistant alike. _client: httpx.AsyncClient | None = None -#: The ``Authorization`` value for tool calls made from inside this process, -#: where no MCP request carries one: the assistant sets it to the token of the -#: person chatting, for the length of their turn. -caller_authorization: ContextVar[str | None] = ContextVar( - "caller_authorization", default=None -) - def set_client(client: httpx.AsyncClient | None) -> None: global _client @@ -77,18 +92,10 @@ async def aclose() -> None: async def forward_caller_auth(request: httpx.Request) -> None: """Carry the caller's bearer token onto the REST call. - Over the endpoint, the transport attaches the originating HTTP request to - every JSON-RPC message, and a tool handler runs inside that request's - context, so the token is read per message rather than per connection. A - call from inside the process has no such request and uses what - :data:`caller_authorization` holds. + A tool handler runs inside the context :data:`caller_authorization` was set + in, by the endpoint's middleware or by the assistant. """ authorization = caller_authorization.get() - try: - source = request_ctx.get().request - except LookupError: - source = None - authorization = getattr(source, "headers", {}).get("authorization") or authorization if authorization: request.headers["authorization"] = authorization diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f87d8e9..5bdd2ff 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # The `inject` node's cron. Imported where it is used, behind a message # that cannot fire while this line is here. "croniter>=1.3.0", - "mcp>=1.30,<2", + "mcp>=2.3,<3", "fluksio-worker>=0.1,<0.2", # The Modules screen installs node code's packages with it, into a venv of # the user's own. Present in the image; a pip install would otherwise have diff --git a/backend/tests/mcp/test_mcp_http.py b/backend/tests/mcp/test_mcp_http.py index e40a77d..87b71cf 100644 --- a/backend/tests/mcp/test_mcp_http.py +++ b/backend/tests/mcp/test_mcp_http.py @@ -51,9 +51,6 @@ def over_mcp(monkeypatch: pytest.MonkeyPatch): from fluksio.mcp import http as mcp_http from fluksio.mcp import server as mcp_server - # A FastMCP instance enters its session manager once, and another test in - # the run may have built one already. - monkeypatch.setattr(mcp_server.mcp, "_session_manager", None, raising=False) mcp_app = mcp_http.build_http_app(app) def run(block: Callable[[httpx.AsyncClient], Awaitable[Any]]) -> Any: @@ -194,6 +191,34 @@ def test_an_agent_can_list_and_call_tools( assert called.json()["result"]["isError"] is False +def test_a_token_for_another_resource_is_refused( + over_mcp, + client: TestClient, + superuser_token_headers: dict[str, str], + agent: uuid.UUID, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The SDK compares the resource the verifier reports with its own URL.""" + from fluksio.mcp import http as mcp_http + + verify = mcp_http._JWTVerifier.verify_token + + async def for_elsewhere(self: Any, token: str) -> Any: + accepted = await verify(self, token) + return accepted.model_copy(update={"resource": "https://other.example/mcp"}) + + monkeypatch.setattr(mcp_http._JWTVerifier, "verify_token", for_elsewhere) + me = client.get( + f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers + ).json() + token = mcp_token(uuid.UUID(me["id"]), agent) + + async def block(http: httpx.AsyncClient) -> httpx.Response: + return await http.post("/mcp", **rpc(token, "tools/list")) + + assert over_mcp(block).status_code == 401 + + def test_revoking_an_agent_stops_the_token_it_already_holds( over_mcp, client: TestClient, @@ -278,7 +303,7 @@ def test_a_real_client_can_connect_and_call_a_tool( ) as http_client: async with streamable_http_client( f"{settings.oauth_issuer}/mcp", http_client=http_client - ) as (read, write, _): + ) as (read, write): async with ClientSession(read, write) as session: initialized = await session.initialize() tools = await session.list_tools() @@ -286,9 +311,13 @@ def test_a_real_client_can_connect_and_call_a_tool( return initialized, tools, called initialized, tools, called = over_mcp(block) - assert initialized.serverInfo.name == "fluksio" + assert initialized.server_info.name == "fluksio" assert {"list_flows", "run_flow"} <= {tool.name for tool in tools.tools} - assert called.isError is False + assert called.is_error is False + # The tool reached the API as the agent's person: refused calls come back + # as an error object in the text, not as a failed call. + answer = json.loads(called.content[0].text) + assert not (isinstance(answer, dict) and "error" in answer), answer @pytest.mark.parametrize("revision", ["2025-03-26", "2025-06-18", "2025-11-25"]) @@ -326,3 +355,36 @@ def test_clients_of_older_protocol_revisions_still_connect( assert initialized.json()["result"]["protocolVersion"] == revision assert listed.status_code == 200 assert "list_flows" in {tool["name"] for tool in listed.json()["result"]["tools"]} + + +def test_a_request_of_the_2026_07_28_revision_is_served( + over_mcp, + client: TestClient, + superuser_token_headers: dict[str, str], + agent: uuid.UUID, +) -> None: + """Its requests carry their own protocol envelope and no handshake.""" + me = client.get( + f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers + ).json() + token = mcp_token(uuid.UUID(me["id"]), agent) + meta = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + } + call = rpc(token, "tools/call", _meta=meta, name="list_flows", arguments={}) + call["headers"] |= { + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "list_flows", + } + + async def block(http: httpx.AsyncClient) -> httpx.Response: + return await http.post("/mcp", **call) + + answer = over_mcp(block) + assert answer.status_code == 200 + result = answer.json()["result"] + assert result["isError"] is False + # Carrying the agent's token on to the API, or the call is refused there. + assert "error" not in json.loads(result["content"][0]["text"]) diff --git a/backend/tests/mcp/test_mcp_tools.py b/backend/tests/mcp/test_mcp_tools.py index 7d4b27c..6f6bf48 100644 --- a/backend/tests/mcp/test_mcp_tools.py +++ b/backend/tests/mcp/test_mcp_tools.py @@ -39,3 +39,36 @@ def test_a_name_cannot_climb_out_of_its_route() -> None: "DELETE /api/v1/flows/%2E%2E", "GET /api/v1/flows/%2E%2E/nodes/%2E%2E%2Fsource/source", ] + + +def test_the_assistant_calls_a_tool_as_the_person_chatting() -> None: + """The in-process path: no MCP request, so the person's token is set by hand.""" + from fluksio.ai import agent + + seen: list[str | None] = [] + + def record(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("authorization")) + return httpx.Response(200, json=[{"name": "home"}]) + + async def calls() -> tuple[list[dict], str]: + previous = server._client + mock = httpx.AsyncClient( + transport=httpx.MockTransport(record), + base_url="http://test", + event_hooks={"request": [server.forward_caller_auth]}, + ) + server.set_client(mock) + try: + specs = await agent.tool_specs() + answer = await agent.mcp_caller("Bearer person")("list_flows", {}) + finally: + server.set_client(previous) + await mock.aclose() + return specs, answer + + specs, answer = asyncio.run(calls()) + assert {"list_flows", "run_flow"} <= {s["function"]["name"] for s in specs} + assert all(s["function"]["parameters"]["type"] == "object" for s in specs) + assert seen == ["Bearer person"] + assert "home" in answer diff --git a/docs/code/agents.md b/docs/code/agents.md index 308849c..2a2c1bd 100644 --- a/docs/code/agents.md +++ b/docs/code/agents.md @@ -14,7 +14,8 @@ Configure your MCP client with an HTTP endpoint: - Deployment: `https:///mcp` The endpoint has to be the address in `FRONTEND_HOST`, which is also the -issuer of the client's tokens. +issuer of the client's tokens. It speaks the streamable HTTP transport without +sessions and answers clients of the MCP revisions 2025-03-26 to 2026-07-28. The client registers, opens browser authorization, and receives an OAuth token after you approve access. The issuer and browser redirects use diff --git a/uv.lock b/uv.lock index 46ac8c0..f1449e4 100644 --- a/uv.lock +++ b/uv.lock @@ -866,7 +866,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "influxdb-client", extras = ["async"], marker = "extra == 'influx'", specifier = ">=1.40.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, - { name = "mcp", specifier = ">=1.30,<2" }, + { name = "mcp", specifier = ">=2.3,<3" }, { name = "numpy", marker = "extra == 'ml'", specifier = ">=2.2.6" }, { name = "orjson", specifier = ">=3.10" }, { name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" }, @@ -1061,6 +1061,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.7.1" @@ -1106,12 +1119,29 @@ wheels = [ ] [[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 = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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]] @@ -1457,15 +1487,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.30.0" +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'" }, @@ -1475,9 +1505,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } +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/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, + { 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]] @@ -1751,6 +1794,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "orjson" version = "3.12.0" @@ -2624,6 +2679,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl", hash = "sha256:cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be", size = 26362, upload-time = "2026-09-15T15:46:19.168Z" }, ] +[[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.21.1"